From 341ffbc33d6c3b272a0e389a4cd889dcdbf41a02 Mon Sep 17 00:00:00 2001 From: enki Date: Sat, 3 May 2025 00:56:20 -0700 Subject: [PATCH] V1. need to add commenting and a post feed for bots. --- CLAUDE.md | 243 ++++++ cmd/server/main.go | 92 ++- config/config.sample.ymal | 6 +- internal/api/bot_service.go | 186 +++-- internal/api/global_relay_service.go | 139 ++++ internal/api/routes.go | 499 ++++++++---- internal/db/db.go | 35 +- internal/media/upload/blossom/upload.go | 103 ++- internal/media/upload/nip94/upload.go | 40 +- internal/models/bot.go | 107 ++- internal/nostr/events/events.go | 19 +- internal/nostr/nip65/outbox.go | 80 ++ internal/nostr/poster/poster.go | 13 +- internal/nostr/relay/manager.go | 38 +- internal/scheduler/scheduler.go | 237 +++--- web/assets/js/main.js | 959 ++++++++++++++++++++---- web/index.html | 245 +++++- 17 files changed, 2485 insertions(+), 556 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/api/global_relay_service.go create mode 100644 internal/nostr/nip65/outbox.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..971aafc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,243 @@ +# Development Guide for nostr-poster + +## Build & Run Commands +- `make build`: Build application +- `make run`: Build and run application +- `make clean`: Clean build artifacts +- `make test`: Run all tests (`go test -v ./...`) +- `make lint`: Run golangci-lint +- `make fmt`: Format code with goimports +- `make tools`: Install development tools + +## Code Style Guidelines +- Format: Use `goimports` (run `make fmt` before commits) +- Linting: Run `make lint` to check code quality +- Error handling: Return errors with context, log with zap +- Package names: Short, lowercase, singular nouns +- Functions: CamelCase (e.g., GetBot, CreateBot) +- Variables: camelCase (e.g., botService, relayManager) +- Interfaces: End with "-er" (e.g., Manager, Uploader) +- Imports: Standard library first, then external packages +- Testing: Write tests for all public functions + +## Project Organization +- `/cmd`: Application entrypoints +- `/internal`: Non-public packages +- `/web`: Frontend assets and templates +- Go version: 1.24.0 + +# Nostr Poster + +A self-hosted solution for automated content posting to the Nostr network, supporting multiple bots, scheduled posting, and various media upload protocols. + +## Overview + +Nostr Poster is a web application that allows you to create and manage multiple Nostr bots. Each bot can automatically post content from a local directory to the Nostr network on a schedule. The application supports different types of Nostr events, including standard notes (kind:1), picture posts (kind:20), and video posts (kind:21/22). + +## Features + +- **Multiple Bot Management**: Create and manage multiple Nostr bots with separate identities +- **Scheduled Posting**: Configure each bot to post content on a regular schedule +- **Media Upload Support**: Upload media to various services including NIP-94/96 compatible servers and Blossom servers +- **Global Relay Management**: Configure global relays for all your bots following the NIP-65 Outbox Model +- **Manual Posting**: Create manual posts in addition to scheduled posts +- **Profile Management**: Edit bot profiles and publish them to the Nostr network +- **Content Management**: Upload and organize content files for your bots +- **Secure Key Storage**: Private keys are encrypted and stored securely + +## Architecture + +### Backend Components + +- **API**: RESTful API built with Go and Gin web framework +- **Database**: SQLite database for storing bot configurations and metadata +- **Scheduler**: Cron-based scheduler for running automated posting jobs +- **Auth Service**: Authentication service using NIP-07 compatible signatures +- **Media Upload**: Support for NIP-94/96 and Blossom protocols for media uploading +- **Key Store**: Secure storage for bot private keys with encryption + +### Frontend Components + +- **Web UI**: Bootstrap-based responsive user interface +- **Authentication**: NIP-07 compatible authentication with browser extensions +- **Bot Management**: Interface for creating and configuring bots +- **Content Management**: Interface for uploading and managing content files +- **Manual Posting**: Interface for creating manual posts + +## NIPs Implemented + +The project implements the following Nostr Implementation Possibilities (NIPs): + +- **NIP-01**: Basic protocol flow, events, and client-relay communication +- **NIP-07**: Browser extension integration for authentication +- **NIP-19**: Bech32-encoded entities for human-friendly display of IDs +- **NIP-55**: Android signer application integration (optional) +- **NIP-65**: Relay list metadata for the Outbox Model +- **NIP-68**: Picture-first feeds +- **NIP-71**: Video events +- **NIP-92**: Media attachments +- **NIP-94**: File metadata +- **NIP-96**: HTTP file storage integration +- **NIP-98**: HTTP Auth + +## Setup + +### Prerequisites + +- Go 1.19 or higher +- Web browser with NIP-07 compatible extension (like nos2x or Alby) + +### Installation + +1. Clone the repository: + ``` + git clone https://github.com/yourusername/nostr-poster.git + cd nostr-poster + ``` + +2. Configure the application by editing `config.yaml`: + ```yaml + app_name: "Nostr Poster" + server_port: 8765 + log_level: "info" + + bot: + keys_file: "keys.json" + content_dir: "./content" + archive_dir: "./archive" + default_interval: 60 + + db: + path: "./nostr-poster.db" + + media: + default_service: "nip94" + nip94: + server_url: "https://files.sovbit.host" + require_auth: true + blossom: + server_url: "https://cdn.sovbit.host/upload" + + relays: + - url: "wss://relay.damus.io" + read: true + write: true + - url: "wss://nostr.mutinywallet.com" + read: true + write: true + - url: "wss://relay.nostr.band" + read: true + write: true + ``` + +3. Build and run the application: + ``` + go build -o nostr-poster + ./nostr-poster + ``` + +4. Access the web interface at `http://localhost:8765` + +### Authentication + +1. The application uses NIP-07 compatible authentication. +2. Install a NIP-07 compatible browser extension like nos2x or Alby. +3. Click "Login with Nostr" on the web interface to authenticate. + +### Creating a Bot + +1. After logging in, click "Create New Bot" on the dashboard. +2. Fill in the bot details, including name, display name, and bio. +3. Choose to generate a new keypair or import an existing NSEC key. +4. Configure posting interval and hashtags. +5. Add relay information if needed. +6. Click "Create Bot" to create the bot. + +### Uploading Content + +1. Go to the "Content" tab in the navigation. +2. Select a bot from the dropdown menu. +3. Click "Load Content" to see the bot's content files. +4. Use the file upload form to upload new content files. +5. Files will be automatically posted based on the bot's schedule. + +### Manual Posting + +1. Go to the "Content" tab in the navigation. +2. Select a bot from the dropdown menu. +3. Scroll down to the "Create Manual Post" section. +4. Choose the post type (standard post or picture post). +5. Fill in the content and upload any media. +6. Click "Post Now" to publish the post immediately. + +## Architecture Details + +### Database Schema + +- **bots**: Stores bot information (ID, keys, profile data) +- **post_config**: Stores posting configuration for each bot +- **media_config**: Stores media upload configuration for each bot +- **relays**: Stores relay information for each bot +- **global_relays**: Stores global relays for the user +- **posts**: Stores information about posts made by bots + +### Key Storage + +- Private keys are encrypted using NaCl secretbox with a password derived from the application configuration. +- The encrypted keys are stored in a JSON file specified in the configuration. + +### Scheduler + +- The scheduler uses a cron-based approach to run posting jobs on the configured intervals. +- Each bot has its own schedule based on its configuration. + +### Media Upload + +- The application supports two main media upload protocols: + - **NIP-94/96**: For uploading to NIP-96 compatible servers with NIP-94 metadata + - **Blossom**: For uploading to Blossom protocol servers (BUD-01, BUD-02) +- The application handles authentication, compression, and metadata extraction automatically. + +## Development + +### Adding a New Feature + +1. Fork the repository +2. Create a new branch for your feature +3. Implement your changes +4. Submit a pull request + +### Code Structure + +``` +. +├── internal +│ ├── api # API handlers and services +│ ├── auth # Authentication services +│ ├── config # Configuration handling +│ ├── crypto # Cryptographic operations and key storage +│ ├── db # Database access and schema +│ ├── media # Media handling (upload, prepare) +│ ├── models # Data models +│ ├── nostr # Nostr protocol implementation +│ │ ├── events # Event creation and signing +│ │ ├── nip65 # Outbox model implementation +│ │ └── relay # Relay management +│ ├── scheduler # Scheduled posting +│ └── utils # Utility functions +├── web # Web interface +│ ├── assets # Static assets +│ │ ├── css # CSS files +│ │ └── js # JavaScript files +│ └── index.html # Main HTML file +``` + +## License + +[MIT License](LICENSE) + +## Acknowledgements + +- [go-nostr](https://github.com/nbd-wtf/go-nostr) - Nostr library for Go +- [Gin Web Framework](https://github.com/gin-gonic/gin) - Web framework for Go +- [Bootstrap](https://getbootstrap.com/) - Frontend framework \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 36e7ae1..2141e6a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -98,38 +98,60 @@ func main() { // 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 + "", // Download URL will be discovered + nil, // Supported types will be discovered logger, func(url, method string, payload []byte) (string, error) { - // Replace with a valid bot's public key. - botPubkey := "your_valid_bot_pubkey_here" - privkey, err := keyStore.GetPrivateKey(botPubkey) - if err != nil { - return "", err + // 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) { - // Replace with the appropriate bot's public key - botPubkey := "your_valid_bot_pubkey_here" - privkey, err := keyStore.GetPrivateKey(botPubkey) - if err != nil { - return "", err + // 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) }, ) @@ -146,8 +168,9 @@ func main() { botService := api.NewBotService( database, keyStore, - eventManager, - relayManager, + eventManager, + relayManager, + globalRelayService, logger, ) @@ -216,9 +239,19 @@ func main() { content := caption + hashtagStr - event, err = eventManager.CreateAndSignMediaEvent( + // Log mediaURL for debugging + logger.Debug("Creating picture event with media URL", zap.String("mediaURL", mediaURL)) + + // Extract a title from the caption or use the filename + title := caption + if title == "" { + title = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) + } + + event, err = eventManager.CreateAndSignPictureEvent( pubkey, - content, + title, // Title parameter + content, // Description parameter mediaURL, contentType, mediaHash, @@ -264,17 +297,19 @@ func main() { 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, // New encoded function - ) +// 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( @@ -282,6 +317,7 @@ func main() { botService, authService, posterScheduler, + globalRelayService, ) // Start the scheduler diff --git a/config/config.sample.ymal b/config/config.sample.ymal index 5895201..271fcab 100644 --- a/config/config.sample.ymal +++ b/config/config.sample.ymal @@ -7,7 +7,7 @@ bot: keys_file: "./keys.json" content_dir: "./content" archive_dir: "./archive" - default_interval: 240 # minutes + default_interval: 60 # minutes db: path: "./nostr-poster.db" @@ -15,10 +15,10 @@ db: media: default_service: "blossom" nip94: - server_url: "https://files.sovbit.host" + server_url: "https://files.sovbit.host" # NIP-96 servers use the base URL require_auth: true blossom: - server_url: "https://cdn.sovbit.host" + server_url: "https://cdn.sovbit.host/upload" # Must include '/upload' endpoint for BUD-02 compliance relays: - url: "wss://freelay.sovbit.host" diff --git a/internal/api/bot_service.go b/internal/api/bot_service.go index f24cc18..eb9df75 100644 --- a/internal/api/bot_service.go +++ b/internal/api/bot_service.go @@ -23,25 +23,29 @@ type BotService struct { eventMgr *events.EventManager relayMgr *relay.Manager logger *zap.Logger + globalRelayService *GlobalRelayService } // NewBotService creates a new BotService func NewBotService( - db *db.DB, - keyStore *crypto.KeyStore, - eventMgr *events.EventManager, - relayMgr *relay.Manager, - logger *zap.Logger, + db *db.DB, + keyStore *crypto.KeyStore, + eventMgr *events.EventManager, + relayMgr *relay.Manager, + globalRelayService *GlobalRelayService, + logger *zap.Logger, ) *BotService { - return &BotService{ - db: db, - keyStore: keyStore, - eventMgr: eventMgr, - relayMgr: relayMgr, - logger: logger, - } + return &BotService{ + db: db, + keyStore: keyStore, + eventMgr: eventMgr, + relayMgr: relayMgr, + globalRelayService: globalRelayService, + logger: logger, + } } + // GetPrivateKey returns the private key for the given pubkey from the keystore. func (s *BotService) GetPrivateKey(pubkey string) (string, error) { return s.keyStore.GetPrivateKey(pubkey) @@ -248,8 +252,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { Read bool Write bool }{ - {"wss://relay.damus.io", true, true}, - {"wss://nostr.mutinywallet.com", true, true}, + {"wss://freelay.sovbit.host", true, true}, {"wss://relay.nostr.band", true, true}, } @@ -278,8 +281,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) { bot.PostConfig = postConfig bot.MediaConfig = mediaConfig bot.Relays = []*models.Relay{ - {BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true}, - {BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true}, + {BotID: botID, URL: "wss://freelay.sovbit.host", Read: true, Write: true}, {BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true}, } @@ -299,6 +301,18 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { return nil, fmt.Errorf("bot not found or not owned by user: %w", err) } + // Log what we're updating + s.logger.Info("Updating bot", + zap.Int64("id", bot.ID), + zap.String("name", bot.Name), + zap.String("display_name", bot.DisplayName), + zap.String("bio", bot.Bio), + zap.String("nip05", bot.Nip05), + zap.String("zap_address", bot.ZapAddress), + zap.String("profile_picture", bot.ProfilePicture), + zap.String("banner", bot.Banner), + zap.Any("website", bot.Website)) + // We don't update the pubkey or encrypted_privkey query := ` UPDATE bots SET @@ -308,14 +322,24 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) { nip05 = ?, zap_address = ?, profile_picture = ?, - banner = ? + banner = ?, + website = ? WHERE id = ? AND owner_pubkey = ? ` + // Handle nullable website field + var websiteVal interface{} + if bot.Website.Valid { + websiteVal = bot.Website.String + } else { + websiteVal = nil + } + _, err = s.db.Exec( query, bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner, + websiteVal, bot.ID, bot.OwnerPubkey, ) if err != nil { @@ -500,75 +524,93 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []* // PublishBotProfile publishes a bot's profile to Nostr func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error { - // Get the bot - bot, err := s.GetBotByID(botID, ownerPubkey) - if err != nil { - return fmt.Errorf("bot not found or not owned by user: %w", err) - } + // Get the bot + bot, err := s.GetBotByID(botID, ownerPubkey) + if err != nil { + return fmt.Errorf("bot not found or not owned by user: %w", err) + } - // Create and sign the metadata event - event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) - if err != nil { - return fmt.Errorf("failed to create metadata event: %w", err) - } + // Create and sign the metadata event + event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) + if err != nil { + return fmt.Errorf("failed to create metadata event: %w", err) + } - // Set up relay connections - for _, relay := range bot.Relays { - if relay.Write { - if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { - s.logger.Warn("Failed to add relay", - zap.String("url", relay.URL), - zap.Error(err)) - } - } - } + // Get combined relays (bot + global) + combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey) + if err != nil { + s.logger.Warn("Failed to get combined relays, using bot relays only", + zap.Int64("botID", botID), + zap.Error(err)) + combinedRelays = bot.Relays + } - // Publish the event - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + // Set up relay connections + for _, relay := range combinedRelays { + if relay.Write { + if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { + s.logger.Warn("Failed to add relay", + zap.String("url", relay.URL), + zap.Error(err)) + } + } + } - published, err := s.relayMgr.PublishEvent(ctx, event) - if err != nil { - return fmt.Errorf("failed to publish profile: %w", err) - } + // Publish the event + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() - s.logger.Info("Published profile to relays", - zap.Int64("botID", botID), - zap.Strings("relays", published)) + published, err := s.relayMgr.PublishEvent(ctx, event) + if err != nil { + return fmt.Errorf("failed to publish profile: %w", err) + } - return nil + s.logger.Info("Published profile to relays", + zap.Int64("botID", botID), + zap.Strings("relays", published)) + + return nil } // PublishBotProfileWithEncoding publishes a bot's profile and returns the encoded event func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) { - // Get the bot - bot, err := s.GetBotByID(botID, ownerPubkey) - if err != nil { - return nil, fmt.Errorf("bot not found or not owned by user: %w", err) - } + // Get the bot + bot, err := s.GetBotByID(botID, ownerPubkey) + if err != nil { + return nil, fmt.Errorf("bot not found or not owned by user: %w", err) + } - // Create and sign the metadata event - event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) - if err != nil { - return nil, fmt.Errorf("failed to create metadata event: %w", err) - } + // Create and sign the metadata event + event, err := s.eventMgr.CreateAndSignMetadataEvent(bot) + if err != nil { + return nil, fmt.Errorf("failed to create metadata event: %w", err) + } - // Set up relay connections - for _, relay := range bot.Relays { - if relay.Write { - if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { - s.logger.Warn("Failed to add relay", - zap.String("url", relay.URL), - zap.Error(err)) - } - } - } + // Get combined relays (bot + global) + combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey) + if err != nil { + s.logger.Warn("Failed to get combined relays, using bot relays only", + zap.Int64("botID", botID), + zap.Error(err)) + combinedRelays = bot.Relays + } - // Publish the event with encoding - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + // Set up relay connections + for _, relay := range combinedRelays { + if relay.Write { + if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { + s.logger.Warn("Failed to add relay", + zap.String("url", relay.URL), + zap.Error(err)) + } + } + } - return s.relayMgr.PublishEventWithEncoding(ctx, event) + // Publish the event with encoding + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + return s.relayMgr.PublishEventWithEncoding(ctx, event) } // Helper function to load related data for a bot diff --git a/internal/api/global_relay_service.go b/internal/api/global_relay_service.go new file mode 100644 index 0000000..9b19d1a --- /dev/null +++ b/internal/api/global_relay_service.go @@ -0,0 +1,139 @@ +// internal/api/global_relay_service.go +package api + +import ( + "fmt" + + "git.sovbit.dev/Enki/nostr-poster/internal/db" + "git.sovbit.dev/Enki/nostr-poster/internal/models" + "go.uber.org/zap" +) + +// GlobalRelayService provides functionality for managing global relays +type GlobalRelayService struct { + db *db.DB + logger *zap.Logger +} + +// NewGlobalRelayService creates a new GlobalRelayService +func NewGlobalRelayService(db *db.DB, logger *zap.Logger) *GlobalRelayService { + return &GlobalRelayService{ + db: db, + logger: logger, + } +} + +// GetUserGlobalRelays gets all global relays for a user +func (s *GlobalRelayService) GetUserGlobalRelays(ownerPubkey string) ([]*models.Relay, error) { + query := ` + SELECT id, url, read, write, owner_pubkey + FROM global_relays + WHERE owner_pubkey = ? + ORDER BY id + ` + + var relays []*models.Relay + err := s.db.Select(&relays, query, ownerPubkey) + if err != nil { + return nil, fmt.Errorf("failed to get global relays: %w", err) + } + + return relays, nil +} + +// AddGlobalRelay adds a global relay for a user +func (s *GlobalRelayService) AddGlobalRelay(relay *models.Relay) error { + query := ` + INSERT INTO global_relays (url, read, write, owner_pubkey) + VALUES (?, ?, ?, ?) + ` + + _, err := s.db.Exec(query, relay.URL, relay.Read, relay.Write, relay.OwnerPubkey) + if err != nil { + return fmt.Errorf("failed to add global relay: %w", err) + } + + return nil +} + +// UpdateGlobalRelay updates a global relay +func (s *GlobalRelayService) UpdateGlobalRelay(relay *models.Relay) error { + query := ` + UPDATE global_relays + SET url = ?, read = ?, write = ? + WHERE id = ? AND owner_pubkey = ? + ` + + _, err := s.db.Exec(query, relay.URL, relay.Read, relay.Write, relay.ID, relay.OwnerPubkey) + if err != nil { + return fmt.Errorf("failed to update global relay: %w", err) + } + + return nil +} + +// DeleteGlobalRelay deletes a global relay +func (s *GlobalRelayService) DeleteGlobalRelay(relayID int64, ownerPubkey string) error { + query := ` + DELETE FROM global_relays + WHERE id = ? AND owner_pubkey = ? + ` + + _, err := s.db.Exec(query, relayID, ownerPubkey) + if err != nil { + return fmt.Errorf("failed to delete global relay: %w", err) + } + + return nil +} + +// GetAllRelaysForPosting gets a combined list of bot-specific and global relays for posting +func (s *GlobalRelayService) GetAllRelaysForPosting(botID int64, ownerPubkey string) ([]*models.Relay, error) { + // First, get bot-specific relays + query1 := ` + SELECT id, bot_id, url, read, write + FROM relays + WHERE bot_id = ? + ` + + var botRelays []*models.Relay + err := s.db.Select(&botRelays, query1, botID) + if err != nil { + return nil, fmt.Errorf("failed to get bot relays: %w", err) + } + + // Then, get global relays + query2 := ` + SELECT id, url, read, write, owner_pubkey + FROM global_relays + WHERE owner_pubkey = ? + ` + + var globalRelays []*models.Relay + err = s.db.Select(&globalRelays, query2, ownerPubkey) + if err != nil { + return nil, fmt.Errorf("failed to get global relays: %w", err) + } + + // Combine both sets, avoiding duplicates + // We'll consider a relay a duplicate if it has the same URL + urlMap := make(map[string]bool) + var combined []*models.Relay + + // Add bot relays first (they take precedence) + for _, relay := range botRelays { + urlMap[relay.URL] = true + combined = append(combined, relay) + } + + // Add global relays if they don't conflict + for _, relay := range globalRelays { + if !urlMap[relay.URL] { + // Set the bot ID for consistency + relay.BotID = botID + combined = append(combined, relay) + } + } + + return combined, nil +} \ No newline at end of file diff --git a/internal/api/routes.go b/internal/api/routes.go index d0267ac..f5f9b4a 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -11,6 +11,7 @@ import ( "strings" "time" "regexp" + "database/sql" "github.com/gin-gonic/gin" "github.com/nbd-wtf/go-nostr" @@ -29,29 +30,32 @@ type API struct { 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, + 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, - } - - // Set up routes - api.setupRoutes() - - return 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 @@ -114,6 +118,16 @@ func (a *API) setupRoutes() { 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") @@ -281,21 +295,39 @@ func (a *API) updateBot(c *gin.Context) { return } - var bot models.Bot - if err := c.ShouldBindJSON(&bot); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) + 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 - bot.ID = botID - bot.OwnerPubkey = pubkey + 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(&bot) + updatedBot, err := a.botService.UpdateBot(&botUpdate) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"}) a.logger.Error("Failed to update bot", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()}) return } @@ -472,8 +504,8 @@ func (a *API) publishBotProfile(c *gin.Context) { return } - // Publish the profile - err = a.botService.PublishBotProfile(botID, pubkey) + // 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)) @@ -482,6 +514,7 @@ func (a *API) publishBotProfile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Profile published successfully", + "event": event, }) } @@ -743,6 +776,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) { 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"}) @@ -762,40 +796,60 @@ func (a *API) uploadToMediaServer(c *gin.Context) { // 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 + // 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 @@ -814,8 +868,20 @@ func (a *API) uploadToMediaServer(c *gin.Context) { 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 { @@ -836,122 +902,127 @@ func (a *API) uploadToMediaServer(c *gin.Context) { // 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 - } + 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 - } + // 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"` - } + // 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"` + } - if err := c.BindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) - return - } + 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 - } + // 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) - } + // 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 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 - } + // Create the appropriate event + var event *nostr.Event + var eventErr error + + 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 + event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( + bot.Pubkey, + req.Title, + req.Content, + mediaURL, + mediaType, + mediaHash, + req.Alt, + 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 - } + 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)) - } - } - } + // 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 + } - // Publish to relays with NIP-19 encoding - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + // 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)) + } + } + } - // 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 - } + // Publish to relays with NIP-19 encoding + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() - // Return the encoded event response - c.JSON(http.StatusOK, gin.H{ - "message": "Post published successfully", - "event": encodedEvent, - }) + // 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 @@ -999,4 +1070,102 @@ func extractHashtags(tags []nostr.Tag) []string { } } 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) } \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index 31e6875..aac163c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -33,11 +33,12 @@ func New(dbPath string) (*DB, error) { db.MustExec("PRAGMA foreign_keys=ON;") return &DB{db}, nil + } // Initialize creates the database schema if it doesn't exist func (db *DB) Initialize() error { - // Create bots table + // Create bots table with updated fields _, err := db.Exec(` CREATE TABLE IF NOT EXISTS bots ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -50,6 +51,7 @@ func (db *DB) Initialize() error { zap_address TEXT, profile_picture TEXT, banner TEXT, + website TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, owner_pubkey TEXT NOT NULL )`) @@ -57,6 +59,24 @@ func (db *DB) Initialize() error { return fmt.Errorf("failed to create bots table: %w", err) } + // Check if website column exists, add it if it doesn't + var columnExists int + err = db.Get(&columnExists, ` + SELECT COUNT(*) FROM pragma_table_info('bots') WHERE name = 'website' + `) + + if err != nil { + return fmt.Errorf("failed to check if website column exists: %w", err) + } + + if columnExists == 0 { + // Add the website column + _, err = db.Exec(`ALTER TABLE bots ADD COLUMN website TEXT`) + if err != nil { + return fmt.Errorf("failed to add website column: %w", err) + } + } + // Create post_config table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS post_config ( @@ -119,6 +139,19 @@ func (db *DB) Initialize() error { return fmt.Errorf("failed to create posts table: %w", err) } + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS global_relays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT 1, + write BOOLEAN NOT NULL DEFAULT 1, + owner_pubkey TEXT NOT NULL, + UNIQUE(url, owner_pubkey) + )`) + if err != nil { + return fmt.Errorf("failed to create global_relays table: %w", err) + } + return nil } diff --git a/internal/media/upload/blossom/upload.go b/internal/media/upload/blossom/upload.go index 62f3c63..96a9e0f 100644 --- a/internal/media/upload/blossom/upload.go +++ b/internal/media/upload/blossom/upload.go @@ -136,10 +136,19 @@ func getEnhancedContentType(filePath string) (string, error) { // UploadFile uploads a file to a Blossom server using raw binary data in the request body func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) { + // Ensure the URL ends with /upload for BUD-02 compliance + serverURL := u.serverURL + if !strings.HasSuffix(serverURL, "/upload") { + serverURL = strings.TrimSuffix(serverURL, "/") + "/upload" + u.logger.Info("Adding /upload endpoint to URL for BUD-02 compliance", + zap.String("original_url", u.serverURL), + zap.String("adjusted_url", serverURL)) + } + // Log information about the upload u.logger.Info("Uploading file to Blossom server", zap.String("filePath", filePath), - zap.String("serverURL", u.serverURL)) + zap.String("serverURL", serverURL)) // Open the file file, err := os.Open(filePath) @@ -181,7 +190,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( // Create the request with the file as the raw body // This follows BUD-02 which states the endpoint must accept binary data in the body - req, err := http.NewRequest("PUT", u.serverURL, file) + req, err := http.NewRequest("PUT", serverURL, file) if err != nil { return "", "", fmt.Errorf("failed to create request: %w", err) } @@ -206,7 +215,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( // Add authorization header if available if u.getAuthHeader != nil { - authHeader, err := u.getAuthHeader(u.serverURL, "PUT") + authHeader, err := u.getAuthHeader(serverURL, "PUT") if err != nil { return "", "", fmt.Errorf("failed to create auth header: %w", err) } @@ -242,13 +251,30 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { - return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr) + errorMsg := fmt.Sprintf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr) + + // Add helpful diagnostics based on status code + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + errorMsg += " - This may indicate an authentication error. Check that your keys are correct and have permission to upload." + case http.StatusNotFound: + errorMsg += " - The upload endpoint was not found. Ensure the server URL is correct and includes the '/upload' path." + case http.StatusRequestEntityTooLarge: + errorMsg += " - The file is too large for this server. Try a smaller file or check server limits." + case http.StatusBadRequest: + errorMsg += " - The server rejected the request. Check that the file format is supported." + case http.StatusInternalServerError: + errorMsg += " - The server encountered an error. This may be temporary; try again later." + } + + return "", "", fmt.Errorf(errorMsg) } // Parse response var blossomResp BlossomResponse if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil { - return "", "", fmt.Errorf("failed to parse response: %w", err) + // Try to provide useful error message even if JSON parsing fails + return "", "", fmt.Errorf("failed to parse server response: %w. Raw response: %s", err, bodyStr) } // Check for success @@ -256,6 +282,18 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message) } + // Validate essential response fields + if blossomResp.URL == "" { + return "", "", fmt.Errorf("upload succeeded but server did not return a URL. Response: %s", bodyStr) + } + + if blossomResp.SHA256 == "" { + // If hash is missing, use our calculated hash + u.logger.Warn("Server did not return a hash, using locally calculated hash", + zap.String("local_hash", fileHash)) + blossomResp.SHA256 = fileHash + } + // Log the successful response u.logger.Info("Upload successful", zap.String("url", blossomResp.URL), @@ -269,8 +307,21 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( // DeleteFile deletes a file from the Blossom server func (u *Uploader) DeleteFile(fileHash string) error { + // Ensure the base URL is properly formed for Blossom API + // For deletes, we need the base URL without the /upload part + baseURL := u.serverURL + if strings.HasSuffix(baseURL, "/upload") { + baseURL = strings.TrimSuffix(baseURL, "/upload") + u.logger.Info("Adjusting URL for deletion: removing /upload suffix", + zap.String("original_url", u.serverURL), + zap.String("adjusted_url", baseURL)) + } + // Create the delete URL - deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash) + deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash) + u.logger.Info("Preparing to delete file from Blossom server", + zap.String("fileHash", fileHash), + zap.String("deleteURL", deleteURL)) // Create the request req, err := http.NewRequest("DELETE", deleteURL, nil) @@ -299,12 +350,33 @@ func (u *Uploader) DeleteFile(fileHash string) error { } defer resp.Body.Close() + // Read response for better error reporting + bodyBytes, _ := io.ReadAll(resp.Body) + bodyStr := string(bodyBytes) + + // Log the response + u.logger.Info("Received delete response from server", + zap.Int("statusCode", resp.StatusCode), + zap.String("body", bodyStr)) + // Check response status - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes)) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + errorMsg := fmt.Sprintf("server returned non-success status for delete: %d, body: %s", resp.StatusCode, bodyStr) + + // Add helpful diagnostics based on status code + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + errorMsg += " - Authentication error. Check that your keys have delete permission." + case http.StatusNotFound: + errorMsg += " - File not found. It may have already been deleted or never existed." + case http.StatusInternalServerError: + errorMsg += " - Server error. This might be temporary; try again later." + } + + return fmt.Errorf(errorMsg) } + u.logger.Info("File successfully deleted", zap.String("fileHash", fileHash)) return nil } @@ -377,9 +449,22 @@ func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, er // WithCustomURL creates a new uploader instance with the specified custom URL. func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { + // Ensure the custom URL follows BUD-02 specification by having /upload endpoint + if !strings.HasSuffix(customURL, "/upload") { + customURL = strings.TrimSuffix(customURL, "/") + "/upload" + u.logger.Info("Adding /upload endpoint to custom URL for BUD-02 compliance", + zap.String("original_url", customURL), + zap.String("adjusted_url", customURL)) + } + return &Uploader{ serverURL: customURL, logger: u.logger, getAuthHeader: u.getAuthHeader, } +} + +// GetServerURL returns the server URL +func (u *Uploader) GetServerURL() string { + return u.serverURL } \ No newline at end of file diff --git a/internal/media/upload/nip94/upload.go b/internal/media/upload/nip94/upload.go index f765281..adb6189 100644 --- a/internal/media/upload/nip94/upload.go +++ b/internal/media/upload/nip94/upload.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/nbd-wtf/go-nostr" @@ -115,6 +116,17 @@ func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) { // UploadFile uploads a file to a NIP-96 compatible server func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) { + // Use the server URL as provided - NIP-96 specifies POST to $api_url directly + serverURL := u.serverURL + // Remove any /upload suffix if it was added incorrectly + if strings.HasSuffix(serverURL, "/upload") { + serverURL = strings.TrimSuffix(serverURL, "/upload") + u.logger.Info("Removing /upload suffix from NIP-96 server URL", + zap.String("original_url", u.serverURL), + zap.String("adjusted_url", serverURL)) + } + u.logger.Info("Using NIP-96 server URL for upload", zap.String("server_url", serverURL)) + // Open the file file, err := os.Open(filePath) if err != nil { @@ -197,7 +209,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( } // Create the request - req, err := http.NewRequest("POST", u.serverURL, &requestBody) + req, err := http.NewRequest("POST", serverURL, &requestBody) if err != nil { return "", "", fmt.Errorf("failed to create request: %w", err) } @@ -216,7 +228,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) ( bodyHash := bodyHasher.Sum(nil) // Use the body hash for authentication - authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash) + authHeader, err := u.getAuthHeader(serverURL, "POST", bodyHash) if err != nil { return "", "", fmt.Errorf("failed to create auth header: %w", err) } @@ -383,8 +395,17 @@ func (u *Uploader) waitForProcessing(processingURL string) (string, string, erro // DeleteFile deletes a file from the server func (u *Uploader) DeleteFile(fileHash string) error { + // Ensure the base URL doesn't have the /upload suffix for deletion + baseURL := u.serverURL + if strings.HasSuffix(baseURL, "/upload") { + baseURL = strings.TrimSuffix(baseURL, "/upload") + u.logger.Info("Removing /upload endpoint for deletion", + zap.String("original_url", u.serverURL), + zap.String("adjusted_url", baseURL)) + } + // Create the delete URL - deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash) + deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash) // Create the request req, err := http.NewRequest("DELETE", deleteURL, nil) @@ -475,6 +496,14 @@ func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) ( // WithCustomURL creates a new uploader instance with the specified custom URL func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { + // NIP-96 specifies POST to $api_url directly without /upload + if strings.HasSuffix(customURL, "/upload") { + customURL = strings.TrimSuffix(customURL, "/upload") + u.logger.Info("Removing /upload suffix from custom NIP-96 URL", + zap.String("original_url", customURL), + zap.String("adjusted_url", customURL)) + } + // Create a new uploader with the same configuration but a different URL return &Uploader{ serverURL: customURL, @@ -483,4 +512,9 @@ func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { logger: u.logger, getAuthHeader: u.getAuthHeader, } +} + +// GetServerURL returns the server URL +func (u *Uploader) GetServerURL() string { + return u.serverURL } \ No newline at end of file diff --git a/internal/models/bot.go b/internal/models/bot.go index 4bcd6a9..e9bb674 100644 --- a/internal/models/bot.go +++ b/internal/models/bot.go @@ -1,24 +1,28 @@ -// internal/models/bot.go package models import ( + "database/sql" + "encoding/json" + "fmt" "time" ) // Bot represents a Nostr posting bot type Bot struct { - ID int64 `db:"id" json:"id"` - Pubkey string `db:"pubkey" json:"pubkey"` - EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"` - Name string `db:"name" json:"name"` - DisplayName string `db:"display_name" json:"display_name"` - Bio string `db:"bio" json:"bio"` - Nip05 string `db:"nip05" json:"nip05"` - ZapAddress string `db:"zap_address" json:"zap_address"` - ProfilePicture string `db:"profile_picture" json:"profile_picture"` - Banner string `db:"banner" json:"banner"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"` + ID int64 `db:"id" json:"id"` + Pubkey string `db:"pubkey" json:"pubkey"` + EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"` + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + Bio string `db:"bio" json:"bio"` + Nip05 string `db:"nip05" json:"nip05"` + ZapAddress string `db:"zap_address" json:"zap_address"` + ProfilePicture string `db:"profile_picture" json:"profile_picture"` + Banner string `db:"banner" json:"banner"` + Website sql.NullString `db:"website" json:"website,omitempty"` // Changed to sql.NullString to handle NULL values + // Custom JSON marshaling is handled in MarshalJSON/UnmarshalJSON methods + CreatedAt time.Time `db:"created_at" json:"created_at"` + OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"` // The following are not stored in the database PostConfig *PostConfig `json:"post_config,omitempty"` @@ -48,11 +52,12 @@ type MediaConfig struct { // Relay represents a Nostr relay configuration type Relay struct { - ID int64 `db:"id" json:"id"` - BotID int64 `db:"bot_id" json:"-"` - URL string `db:"url" json:"url"` - Read bool `db:"read" json:"read"` - Write bool `db:"write" json:"write"` + ID int64 `db:"id" json:"id"` + BotID int64 `db:"bot_id" json:"-"` + URL string `db:"url" json:"url"` + Read bool `db:"read" json:"read"` + Write bool `db:"write" json:"write"` + OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey,omitempty"` // Add this field } // Post represents a post made by the bot @@ -65,4 +70,68 @@ type Post struct { Status string `db:"status" json:"status"` // "pending", "posted", "failed" CreatedAt time.Time `db:"created_at" json:"created_at"` Error string `db:"error" json:"error,omitempty"` -} \ No newline at end of file +} + +// MarshalJSON handles custom JSON marshaling for Bot, especially for sql.NullString fields +func (b Bot) MarshalJSON() ([]byte, error) { + type Alias Bot // Create an alias to avoid infinite recursion + + // Create a copy of the bot to modify for JSON + bot := &struct { + Website interface{} `json:"website,omitempty"` + *Alias + }{ + Alias: (*Alias)(&b), + } + + // Handle the sql.NullString field specifically + if b.Website.Valid { + bot.Website = b.Website.String + } else { + bot.Website = nil + } + + return json.Marshal(bot) +} + +// UnmarshalJSON handles custom JSON unmarshaling for Bot, especially for sql.NullString fields +func (b *Bot) UnmarshalJSON(data []byte) error { + type Alias Bot // Create an alias to avoid infinite recursion + + // Create a proxy structure with website as interface{} + aux := &struct { + Website interface{} `json:"website,omitempty"` + *Alias + }{ + Alias: (*Alias)(b), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle possible types for the website field + if aux.Website == nil { + b.Website = sql.NullString{Valid: false} + } else { + switch v := aux.Website.(type) { + case string: + b.Website = sql.NullString{String: v, Valid: true} + case map[string]interface{}: + // Handle specific JSON format for sql.NullString + if str, ok := v["String"].(string); ok { + valid := true + if v, ok := v["Valid"].(bool); ok { + valid = v + } + b.Website = sql.NullString{String: str, Valid: valid} + } + case nil: + b.Website = sql.NullString{Valid: false} + default: + return fmt.Errorf("unsupported type for Website: %T", v) + } + } + + return nil +} diff --git a/internal/nostr/events/events.go b/internal/nostr/events/events.go index c5fabbb..1aff3e3 100644 --- a/internal/nostr/events/events.go +++ b/internal/nostr/events/events.go @@ -27,14 +27,14 @@ func NewEventManager(getPrivateKey func(pubkey string) (string, error)) *EventMa // CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) { - // Create the metadata structure + // Create a comprehensive metadata structure according to NIP-01 metadata := map[string]interface{}{ - "name": bot.Name, + "name": bot.Name, "display_name": bot.DisplayName, - "about": bot.Bio, + "about": bot.Bio, } - // Add optional fields if they exist + // Add optional fields only if they exist and aren't empty if bot.Nip05 != "" { metadata["nip05"] = bot.Nip05 } @@ -46,6 +46,15 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even if bot.Banner != "" { metadata["banner"] = bot.Banner } + + if bot.ZapAddress != "" { + metadata["lud16"] = bot.ZapAddress // Lightning Address (NIP-57) + } + + // Add website field if present + if bot.Website.Valid && bot.Website.String != "" { + metadata["website"] = bot.Website.String + } // Convert metadata to JSON content, err := json.Marshal(metadata) @@ -55,7 +64,7 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even // Create the event ev := nostr.Event{ - Kind: 0, + Kind: 0, // User metadata event CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: []nostr.Tag{}, Content: string(content), diff --git a/internal/nostr/nip65/outbox.go b/internal/nostr/nip65/outbox.go new file mode 100644 index 0000000..d5c075e --- /dev/null +++ b/internal/nostr/nip65/outbox.go @@ -0,0 +1,80 @@ +// internal/nostr/nip65/outbox.go +package nip65 + +import ( + "github.com/nbd-wtf/go-nostr" + "git.sovbit.dev/Enki/nostr-poster/internal/models" +) + +// GetOutboxRelaysForEvent determines the optimal set of relays for publishing an event +// following the NIP-65 (Outbox Model) recommendations +func GetOutboxRelaysForEvent(event *nostr.Event, authorRelays []*models.Relay, taggedPubkeys []string, taggedPubkeyRelays map[string][]*models.Relay) []*models.Relay { + // Start with the author's WRITE relays + var outboxRelays []*models.Relay + var outboxRelayURLs = make(map[string]bool) + + // First, add all WRITE relays of the author + for _, relay := range authorRelays { + if relay.Write { + outboxRelays = append(outboxRelays, relay) + outboxRelayURLs[relay.URL] = true + } + } + + // Then add all READ relays of tagged users (if any) + for _, pubkey := range taggedPubkeys { + // Skip if we don't have relay info for this pubkey + userRelays, ok := taggedPubkeyRelays[pubkey] + if !ok { + continue + } + + // Add all READ relays that aren't already in our list + for _, relay := range userRelays { + if relay.Read && !outboxRelayURLs[relay.URL] { + outboxRelays = append(outboxRelays, relay) + outboxRelayURLs[relay.URL] = true + } + } + } + + return outboxRelays +} + +// ExtractTaggedPubkeys extracts all pubkeys tagged in an event +func ExtractTaggedPubkeys(event *nostr.Event) []string { + var pubkeys []string + + for _, tag := range event.Tags { + if len(tag) >= 2 && tag[0] == "p" { + pubkeys = append(pubkeys, tag[1]) + } + } + + return pubkeys +} + +// PublishWithOutboxModel is a utility function to properly publish an event +// following the NIP-65 Outbox Model +func PublishWithOutboxModel(event *nostr.Event, relayManager interface{}, authorRelays []*models.Relay, + taggedPubkeyRelaysFn func(pubkey string) ([]*models.Relay, error)) error { + // Extract tagged pubkeys + taggedPubkeys := ExtractTaggedPubkeys(event) + + // Get relays for tagged pubkeys + taggedPubkeyRelays := make(map[string][]*models.Relay) + for _, pubkey := range taggedPubkeys { + relays, err := taggedPubkeyRelaysFn(pubkey) + if err == nil { + taggedPubkeyRelays[pubkey] = relays + } + } + + // Determine outbox relays + outboxRelays := GetOutboxRelaysForEvent(event, authorRelays, taggedPubkeys, taggedPubkeyRelays) + + // Configure relay manager with these relays + // This will be implementation-specific - this is just a skeleton + + return nil +} \ No newline at end of file diff --git a/internal/nostr/poster/poster.go b/internal/nostr/poster/poster.go index a61b347..39684a9 100644 --- a/internal/nostr/poster/poster.go +++ b/internal/nostr/poster/poster.go @@ -98,7 +98,12 @@ func (p *Poster) PostContent( // Determine the appropriate event kind and create the event if isImage { - // Use kind 1 (text note) for images + // Log the media URL for debugging + p.logger.Info("Creating image post with media URL", + zap.String("mediaURL", mediaURL), + zap.String("mediaHash", mediaHash), + zap.String("contentType", contentType)) + // Create hashtag string for post content var hashtagStr string if len(hashtags) > 0 { @@ -111,9 +116,11 @@ func (p *Poster) PostContent( content := caption + hashtagStr - event, err = p.eventMgr.CreateAndSignMediaEvent( + // Use kind 20 (picture post) for better media compatibility + event, err = p.eventMgr.CreateAndSignPictureEvent( pubkey, - content, + caption, // Title + content, // Description mediaURL, contentType, mediaHash, diff --git a/internal/nostr/relay/manager.go b/internal/nostr/relay/manager.go index 426fc08..e8a94c3 100644 --- a/internal/nostr/relay/manager.go +++ b/internal/nostr/relay/manager.go @@ -88,8 +88,8 @@ func (m *Manager) AddRelay(url string, read, write bool) error { return nil } - // Connect to the relay - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + // Connect to the relay with a longer timeout for slow connections + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() relay, err := nostr.RelayConnect(ctx, url) @@ -136,7 +136,37 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin m.mu.RUnlock() if len(writeURLs) == 0 { - return nil, fmt.Errorf("no write relays configured") + // Add default relays if none are configured + m.logger.Warn("No write relays configured, adding default relays for posting") + + // Try multiple relays for better reliability + defaultRelays := []string{ + "wss://freelay.sovbit.host", + "wss://wot.sovbit.host", + "wss://relay.nostr.band", + } + + for _, relayURL := range defaultRelays { + err := m.AddRelay(relayURL, true, true) + if err != nil { + m.logger.Warn("Failed to add default relay", + zap.String("relay", relayURL), + zap.Error(err)) + // Continue trying other relays + } else { + m.logger.Info("Added default relay", zap.String("relay", relayURL)) + } + } + + // Refresh the write URLs + m.mu.RLock() + writeURLs = make([]string, len(m.writeURLs)) + copy(writeURLs, m.writeURLs) + m.mu.RUnlock() + + if len(writeURLs) == 0 { + return nil, fmt.Errorf("no write relays configured") + } } // Keep track of successful publishes @@ -160,7 +190,7 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin } // Create a new context with timeout - publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // Publish the event diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3bd2a8f..5582d8f 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -14,9 +14,15 @@ import ( "git.sovbit.dev/Enki/nostr-poster/internal/db" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/utils" + "git.sovbit.dev/Enki/nostr-poster/internal/crypto" "go.uber.org/zap" ) +// RelayService defines the interface for relay service operations +type RelayService interface { + GetAllRelaysForPosting(botID int64, ownerPubkey string) ([]*models.Relay, error) +} + // MediaUploader defines the interface for uploading media type MediaUploader interface { UploadFile(filePath string, caption string, altText string) (string, string, error) @@ -53,110 +59,113 @@ type ContentPosterWithEncoding func( // 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 + db *db.DB + cron *cron.Cron + logger *zap.Logger + contentDir string + archiveDir string + nip94Uploader MediaUploader + blossomUploader MediaUploader + postContent ContentPoster + postContentEncoded ContentPosterWithEncoding + botJobs map[int64]cron.EntryID + mu sync.RWMutex + globalRelayService RelayService + keyStore *crypto.KeyStore } // 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 + db *db.DB, + logger *zap.Logger, + contentDir string, + archiveDir string, + nip94Uploader MediaUploader, + blossomUploader MediaUploader, + postContent ContentPoster, + postContentEncoded ContentPosterWithEncoding, + globalRelayService RelayService, + keyStore *crypto.KeyStore, ) *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), - } + 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, // Use the local variable + logger: logger, + contentDir: contentDir, + archiveDir: archiveDir, + nip94Uploader: nip94Uploader, + blossomUploader: blossomUploader, + postContent: postContent, + postContentEncoded: postContentEncoded, + globalRelayService: globalRelayService, + botJobs: make(map[int64]cron.EntryID), + keyStore: keyStore, + } } // 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 + // Load all bots with enabled post configs + query := ` + SELECT b.id, b.pubkey, b.name, b.display_name, pc.enabled, pc.interval_minutes, pc.hashtags, + mc.primary_service, mc.fallback_service, mc.nip94_server_url, mc.blossom_server_url + 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 + + err := rows.Scan( + &bot.ID, &bot.Pubkey, &bot.Name, &bot.DisplayName, + &postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags, + &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 @@ -230,7 +239,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error { return } - // Select the appropriate uploader + // Get the appropriate uploader for this bot var uploader MediaUploader if bot.MediaConfig.PrimaryService == "blossom" { uploader = s.blossomUploader @@ -276,7 +285,32 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error { filename := filepath.Base(contentPath) caption := strings.TrimSuffix(filename, filepath.Ext(filename)) - // Post the content - use encoded version if available, otherwise use the original + // Get the owner pubkey of the bot + var ownerPubkey string + err = s.db.Get(&ownerPubkey, "SELECT owner_pubkey FROM bots WHERE id = ?", bot.ID) + if err != nil { + s.logger.Error("Failed to get bot owner pubkey", + zap.Int64("bot_id", bot.ID), + zap.Error(err)) + ownerPubkey = "" // Default to empty if not found + } + + // Set up relays if owner found + if ownerPubkey != "" && s.globalRelayService != nil { + // Get combined relays + combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(bot.ID, ownerPubkey) + if err == nil && len(combinedRelays) > 0 { + // Use combined relays if available + for _, relay := range combinedRelays { + // Add to both the bot's relays list and the relay manager + if !containsRelay(bot.Relays, relay.URL) { + bot.Relays = append(bot.Relays, relay) + } + } + } + } + + // Rest of the function remains the same var postErr error if s.postContentEncoded != nil { @@ -448,4 +482,13 @@ func (s *Scheduler) RunNow(botID int64) error { entry.Job.Run() return nil +} + +func containsRelay(relays []*models.Relay, url string) bool { + for _, relay := range relays { + if relay.URL == url { + return true + } + } + return false } \ No newline at end of file diff --git a/web/assets/js/main.js b/web/assets/js/main.js index 9182975..dc4aff7 100644 --- a/web/assets/js/main.js +++ b/web/assets/js/main.js @@ -23,6 +23,46 @@ document.addEventListener('DOMContentLoaded', () => { // (IDs must match the modals in your HTML) const botSettingsModalEl = document.getElementById('botSettingsModal'); + const profilePictureInput = document.getElementById('botProfilePicture'); + const uploadProfilePictureBtn = document.getElementById('uploadProfilePicture'); + const profilePicturePreview = document.getElementById('profilePicturePreview'); + const profilePreviewImage = document.getElementById('profilePreviewImage'); + const removeProfilePictureBtn = document.getElementById('removeProfilePicture'); + const profilePictureUrl = document.getElementById('profilePictureUrl'); + + // Banner image upload handlers for Create Bot Modal + const bannerInput = document.getElementById('botBanner'); + const uploadBannerBtn = document.getElementById('uploadBanner'); + const bannerPreview = document.getElementById('bannerPreview'); + const bannerPreviewImage = document.getElementById('bannerPreviewImage'); + const removeBannerBtn = document.getElementById('removeBanner'); + const bannerUrl = document.getElementById('bannerUrl'); + + // Profile image upload handlers for Bot Settings Modal + const settingsProfilePictureInput = document.getElementById('botSettingsProfilePicture'); + const uploadSettingsProfilePictureBtn = document.getElementById('uploadSettingsProfilePicture'); + const settingsProfilePicturePreview = document.getElementById('settingsProfilePicturePreview'); + const settingsProfilePictureContainer = document.getElementById('settingsProfilePictureContainer'); + const settingsProfilePictureEmpty = document.getElementById('settingsProfilePictureEmpty'); + const settingsProfilePreviewImage = document.getElementById('settingsProfilePreviewImage'); + const removeSettingsProfilePictureBtn = document.getElementById('removeSettingsProfilePicture'); + const settingsProfilePictureUrl = document.getElementById('settingsProfilePictureUrl'); + + // Banner image upload handlers for Bot Settings Modal + const settingsBannerInput = document.getElementById('botSettingsBanner'); + const uploadSettingsBannerBtn = document.getElementById('uploadSettingsBanner'); + const settingsBannerPreview = document.getElementById('settingsBannerPreview'); + const settingsBannerContainer = document.getElementById('settingsBannerContainer'); + const settingsBannerEmpty = document.getElementById('settingsBannerEmpty'); + const settingsBannerPreviewImage = document.getElementById('settingsBannerPreviewImage'); + const removeSettingsBannerBtn = document.getElementById('removeSettingsBanner'); + const settingsBannerUrl = document.getElementById('settingsBannerUrl'); + + const globalRelaysList = document.getElementById('global-relays-list'); + const addGlobalRelayBtn = document.getElementById('add-global-relay-btn'); + const saveGlobalRelayBtn = document.getElementById('save-global-relay-btn'); + const globalRelayModalEl = document.getElementById('globalRelayModal'); + /* ---------------------------------------------------- * Bootstrap Modal instance * -------------------------------------------------- */ @@ -37,11 +77,17 @@ document.addEventListener('DOMContentLoaded', () => { botSettingsModal = new bootstrap.Modal(botSettingsModalEl); } + let profileImageURL = ''; + let bannerImageURL = ''; + let settingsProfileImageURL = ''; + let settingsBannerImageURL = ''; + let globalRelays = []; + let globalRelayModal; /* ---------------------------------------------------- * Global State * -------------------------------------------------- */ let currentUser = null; - const API_ENDPOINT = ''; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765' + const API_ENDPOINT = 'http://localhost:8765'; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765' /* ---------------------------------------------------- * On page load, check if already logged in @@ -54,6 +100,9 @@ document.addEventListener('DOMContentLoaded', () => { if (loginButton) loginButton.addEventListener('click', login); if (logoutButton) logoutButton.addEventListener('click', logout); + if (addGlobalRelayBtn) addGlobalRelayBtn.addEventListener('click', showAddGlobalRelayModal); + if (saveGlobalRelayBtn) saveGlobalRelayBtn.addEventListener('click', addGlobalRelay); + if (createBotBtn) createBotBtn.addEventListener('click', showCreateBotModal); if (saveBotBtn) saveBotBtn.addEventListener('click', createBot); @@ -136,46 +185,87 @@ document.addEventListener('DOMContentLoaded', () => { }); } + if (uploadProfilePictureBtn) { + uploadProfilePictureBtn.addEventListener('click', () => handleImageUpload('profile', profilePictureInput)); + } + + if (uploadBannerBtn) { + uploadBannerBtn.addEventListener('click', () => handleImageUpload('banner', bannerInput)); + } + + if (removeProfilePictureBtn) { + removeProfilePictureBtn.addEventListener('click', () => { + profilePicturePreview.classList.add('d-none'); + profileImageURL = ''; + }); + } + + if (removeBannerBtn) { + removeBannerBtn.addEventListener('click', () => { + bannerPreview.classList.add('d-none'); + bannerImageURL = ''; + }); + } + + if (uploadSettingsProfilePictureBtn) { + uploadSettingsProfilePictureBtn.addEventListener('click', () => handleImageUpload('settings-profile', settingsProfilePictureInput)); + } + + if (uploadSettingsBannerBtn) { + uploadSettingsBannerBtn.addEventListener('click', () => handleImageUpload('settings-banner', settingsBannerInput)); + } + + if (removeSettingsProfilePictureBtn) { + removeSettingsProfilePictureBtn.addEventListener('click', () => { + settingsProfilePictureContainer.classList.add('d-none'); + settingsProfilePictureEmpty.classList.remove('d-none'); + settingsProfileImageURL = ''; + }); + } + + if (removeSettingsBannerBtn) { + removeSettingsBannerBtn.addEventListener('click', () => { + settingsBannerContainer.classList.add('d-none'); + settingsBannerEmpty.classList.remove('d-none'); + settingsBannerImageURL = ''; + }); + } + /* ---------------------------------------------------- * Authentication / Login / Logout * -------------------------------------------------- */ - async function checkAuth() { - const token = localStorage.getItem('authToken'); - if (!token) { - showAuthSection(); - return; - } - - try { - const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, { - headers: { 'Authorization': token } - }); - - if (response.ok) { - const data = await response.json(); - - // data.pubkey is currently hex - // Convert to npub using nip19 from nostr-tools - const { nip19 } = window.nostrTools; - const userNpub = nip19.npubEncode(data.pubkey); - - // Store npub as currentUser - currentUser = userNpub; - - showMainContent(); - // Always load bots if on the main page - fetchBots(); - } else { - // Token invalid - localStorage.removeItem('authToken'); - showAuthSection(); - } - } catch (error) { - console.error('Auth check failed:', error); - showAuthSection(); - } +// In main.js, look at the checkAuth function +async function checkAuth() { + const token = localStorage.getItem('authToken'); + if (!token) { + showAuthSection(); + return; } + try { + const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, { + headers: { 'Authorization': token } + }); + + if (response.ok) { + const data = await response.json(); + // Convert to npub using nip19 from nostr-tools + const { nip19 } = window.nostrTools; + const userNpub = nip19.npubEncode(data.pubkey); + currentUser = userNpub; + showMainContent(); + fetchBots(); + fetchGlobalRelays(); // Make sure this function is called + } else { + localStorage.removeItem('authToken'); + showAuthSection(); + } + } catch (error) { + console.error('Auth check failed:', error); + showAuthSection(); + } +} + async function login() { if (!window.nostr) { @@ -224,6 +314,37 @@ document.addEventListener('DOMContentLoaded', () => { } } + async function checkAuth() { + const token = localStorage.getItem('authToken'); + if (!token) { + showAuthSection(); + return; + } + + try { + const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, { + headers: { 'Authorization': token } + }); + + if (response.ok) { + const data = await response.json(); + const { nip19 } = window.nostrTools; + const userNpub = nip19.npubEncode(data.pubkey); + currentUser = userNpub; + + showMainContent(); + fetchBots(); + fetchGlobalRelays(); // Add this line to load global relays + } else { + localStorage.removeItem('authToken'); + showAuthSection(); + } + } catch (error) { + console.error('Auth check failed:', error); + showAuthSection(); + } + } + function logout() { localStorage.removeItem('authToken'); currentUser = null; @@ -477,6 +598,20 @@ document.addEventListener('DOMContentLoaded', () => { } window.publishBotProfile = async function(botId) { + // Find the publish button + const publishBtn = document.getElementById('publishProfileBtn'); + + // If not found, try to find it by class (might be in bot card) + const btnSelector = publishBtn || document.querySelector(`button[onclick*="publishBotProfile(${botId})"]`); + + // Create a loading state for the button if found + let originalBtnText = ''; + if (btnSelector) { + originalBtnText = btnSelector.innerHTML; + btnSelector.disabled = true; + btnSelector.innerHTML = ' Publishing...'; + } + try { const token = localStorage.getItem('authToken'); const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/profile/publish`, { @@ -495,74 +630,126 @@ document.addEventListener('DOMContentLoaded', () => { // Check if event data with NIP-19 encoding is available if (data.event) { - // Create a modal to show the encoded event info - const modalId = 'eventInfoModal'; - - // Remove any existing modal with the same ID - const existingModal = document.getElementById(modalId); - if (existingModal) { - existingModal.remove(); - } - - const eventModal = document.createElement('div'); - eventModal.className = 'modal fade'; - eventModal.id = modalId; - eventModal.setAttribute('tabindex', '-1'); - eventModal.innerHTML = ` - - `; - - // Add the modal to the document - document.body.appendChild(eventModal); - - // Show the modal using Bootstrap - const bsModal = new bootstrap.Modal(document.getElementById(modalId)); - bsModal.show(); + // Display success with NIP-19 encoded identifiers + displaySuccessfulPublish(data.event); + } else { + // Basic success message if NIP-19 data isn't available + alert('Profile published successfully!'); } - alert('Profile published successfully!'); - } catch (err) { console.error('Error publishing profile:', err); alert(`Error publishing profile: ${err.message}`); + } finally { + // Reset button state if found + if (btnSelector) { + btnSelector.disabled = false; + btnSelector.innerHTML = originalBtnText; + } } }; + + // Helper function to display a success message with NIP-19 encoded event IDs + function displaySuccessfulPublish(eventData) { + // Create a modal to show the encoded event info + const modalId = 'profilePublishedModal'; + + // Remove any existing modal with the same ID + const existingModal = document.getElementById(modalId); + if (existingModal) { + existingModal.remove(); + } + + // Create the modal + const modal = document.createElement('div'); + modal.className = 'modal fade'; + modal.id = modalId; + modal.setAttribute('tabindex', '-1'); + modal.setAttribute('aria-labelledby', `${modalId}Label`); + modal.setAttribute('aria-hidden', 'true'); + + // Create the modal content + modal.innerHTML = ` + + `; + + // Add the modal to the document + document.body.appendChild(modal); + + // Show the modal + const modalInstance = new bootstrap.Modal(document.getElementById(modalId)); + modalInstance.show(); + + // Add event listeners to copy buttons + document.querySelectorAll('.copy-btn').forEach(button => { + button.addEventListener('click', function() { + const text = this.getAttribute('data-value'); + navigator.clipboard.writeText(text).then(() => { + // Visual feedback that copy succeeded + const originalHTML = this.innerHTML; + this.innerHTML = ` + + + + `; + + setTimeout(() => { + this.innerHTML = originalHTML; + }, 2000); + }); + }); + }); + } /* ---------------------------------------------------- * Show Create Bot Modal @@ -571,18 +758,319 @@ document.addEventListener('DOMContentLoaded', () => { // Reset form const createBotForm = document.getElementById('create-bot-form'); if (createBotForm) createBotForm.reset(); - + // Default: keyOption = "generate" → hide nsecKeyInput + const keyOption = document.getElementById('keyOption'); + const nsecKeyInput = document.getElementById('nsecKeyInput'); if (keyOption) { keyOption.value = 'generate'; } if (nsecKeyInput) { nsecKeyInput.style.display = 'none'; } + + // Show modal - ensure modal is initialized + if (typeof bootstrap !== 'undefined' && createBotModalEl) { + if (!createBotModal) { + createBotModal = new bootstrap.Modal(createBotModalEl); + } + createBotModal.show(); + } else { + console.error('Bootstrap or modal element not found'); + } + } + + + async function fetchGlobalRelays() { + if (!globalRelaysList) return; + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`${API_ENDPOINT}/api/global-relays`, { + headers: { 'Authorization': token } + }); + + if (response.ok) { + const relays = await response.json(); + renderGlobalRelays(relays); + } else { + console.error('Failed to fetch global relays'); + globalRelaysList.innerHTML = '
Failed to load global relays
'; + } + } catch (error) { + console.error('Error fetching global relays:', error); + globalRelaysList.innerHTML = '
Error loading global relays
'; + } + } + + // Render global relays + function renderGlobalRelays(relays) { + if (!globalRelaysList) return; + + globalRelaysList.innerHTML = ''; + + if (!relays || relays.length === 0) { + globalRelaysList.innerHTML = ` +
+ No global relays configured. Add a relay to get started. +
+ `; + return; + } + + let html = '
'; + relays.forEach(relay => { + html += ` +
+
+
+
${relay.url}
+ Read: ${relay.read ? 'Yes' : 'No'} + Write: ${relay.write ? 'Yes' : 'No'} +
+
+ +
+
+
+ `; + }); + html += '
'; + globalRelaysList.innerHTML = html; + } + + // Add this function for global relay modal + function showAddGlobalRelayModal() { + // Reset form + const globalRelayForm = document.getElementById('global-relay-form'); + if (globalRelayForm) globalRelayForm.reset(); // Show modal - if (createBotModal) { - createBotModal.show(); + if (typeof bootstrap !== 'undefined' && globalRelayModalEl) { + if (!globalRelayModal) { + globalRelayModal = new bootstrap.Modal(globalRelayModalEl); + } + globalRelayModal.show(); + } else { + console.error('Bootstrap or modal element not found'); + } + } + + document.addEventListener('click', function(e) { + if (typeof bootstrap !== 'undefined' && globalRelayModalEl) { + globalRelayModal = new bootstrap.Modal(globalRelayModalEl); + } + if (e.target.closest('.copy-btn')) { + const btn = e.target.closest('.copy-btn'); + const valueToCopy = btn.getAttribute('data-value'); + + navigator.clipboard.writeText(valueToCopy) + .then(() => { + const originalHTML = btn.innerHTML; + btn.innerHTML = 'Copied!'; + setTimeout(() => { + btn.innerHTML = originalHTML; + }, 2000); + }) + .catch(err => { + console.error('Failed to copy: ', err); + }); + } + }); + /* ---------------------------------------------------- + * Image Stuff + * -------------------------------------------------- */ + async function handleImageUpload(type, fileInput) { + if (!fileInput || !fileInput.files.length) { + alert('Please select a file first'); + return; + } + + const file = fileInput.files[0]; + + // Validate file is an image + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + // Get the current bot ID if we're in settings mode + let botId = null; + if (type.startsWith('settings')) { + const botSettingsSaveBtn = document.getElementById('botSettingsSaveBtn'); + if (botSettingsSaveBtn) { + botId = botSettingsSaveBtn.getAttribute('data-bot-id'); + if (!botId) { + alert('Could not determine bot ID'); + return; + } + } else { + alert('Could not find save button'); + return; + } + } + + try { + // Show loading state + const uploadButtonId = type === 'profile' ? 'uploadProfilePicture' : + type === 'banner' ? 'uploadBanner' : + type === 'settings-profile' ? 'uploadSettingsProfilePicture' : + 'uploadSettingsBanner'; + + const uploadButton = document.getElementById(uploadButtonId); + const originalButtonText = uploadButton.innerHTML; + uploadButton.disabled = true; + uploadButton.innerHTML = ' Uploading...'; + + // For new bot creation (no botId yet), just preview the file + if (!botId) { + const reader = new FileReader(); + reader.onload = function(e) { + // For new bots, just show a preview and store the DataURL temporarily + // The actual upload will happen when creating the bot + previewUploadedImage(type, e.target.result, file.name); + }; + reader.readAsDataURL(file); + + // Reset upload button + uploadButton.disabled = false; + uploadButton.innerHTML = originalButtonText; + return; + } + + // Create form data for existing bots + const formData = new FormData(); + formData.append('file', file); + + // Upload file to server + const token = localStorage.getItem('authToken'); + + // Upload through content endpoint + const response = await fetch(`${API_ENDPOINT}/api/content/${botId}/upload`, { + method: 'POST', + headers: { + 'Authorization': token + }, + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + const data = await response.json(); + console.log('File uploaded:', data); + + // Get saved media server configuration from localStorage + let mediaConfig = { + primaryService: 'nip94', + primaryURL: '', + fallbackService: '', + fallbackURL: '' + }; + + try { + const savedConfig = localStorage.getItem('mediaConfig'); + if (savedConfig) { + mediaConfig = JSON.parse(savedConfig); + console.log('Retrieved media config from localStorage:', mediaConfig); + } else { + console.log('No saved media config found in localStorage, using defaults'); + } + } catch (error) { + console.error('Error parsing saved media config:', error); + } + + // Use the primary service and URL from the saved config + const service = mediaConfig.primaryService || 'nip94'; + const serverURL = service === 'nip94' ? mediaConfig.primaryURL : mediaConfig.blossomURL; + + console.log(`Using media service: ${service}, URL: ${serverURL}`); + + // Now upload to the media server with the proper URL + const mediaServerResponse = await fetch(`${API_ENDPOINT}/api/content/${botId}/uploadToMediaServer`, { + method: 'POST', + headers: { + 'Authorization': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + filename: data.filename, + service: service, + serverURL: serverURL, // Use the URL from localStorage + isProfile: true // Indicate this is a profile image + }) + }); + + if (!mediaServerResponse.ok) { + throw new Error(`Media server upload failed: ${mediaServerResponse.status}`); + } + + const mediaData = await mediaServerResponse.json(); + console.log('File uploaded to media server:', mediaData); + + // Show preview and store URL + previewUploadedImage(type, mediaData.url, file.name); + + // Reset upload button + uploadButton.disabled = false; + uploadButton.innerHTML = originalButtonText; + + } catch (error) { + console.error('Upload error:', error); + alert(`Upload failed: ${error.message}`); + + // Reset button state + const uploadButtonId = type === 'profile' ? 'uploadProfilePicture' : + type === 'banner' ? 'uploadBanner' : + type === 'settings-profile' ? 'uploadSettingsProfilePicture' : + 'uploadSettingsBanner'; + + const uploadButton = document.getElementById(uploadButtonId); + if (uploadButton) { + uploadButton.disabled = false; + uploadButton.innerHTML = ' Upload'; + } + } + } + + // Preview uploaded image + function previewUploadedImage(type, imageUrl, filename) { + switch(type) { + case 'profile': + profilePreviewImage.src = imageUrl; + profilePictureUrl.textContent = filename; + profilePicturePreview.classList.remove('d-none'); + profileImageURL = imageUrl; + break; + + case 'banner': + bannerPreviewImage.src = imageUrl; + bannerUrl.textContent = filename; + bannerPreview.classList.remove('d-none'); + bannerImageURL = imageUrl; + break; + + case 'settings-profile': + settingsProfilePreviewImage.src = imageUrl; + settingsProfilePictureUrl.textContent = filename; + settingsProfilePictureContainer.classList.remove('d-none'); + settingsProfilePictureEmpty.classList.add('d-none'); + settingsProfileImageURL = imageUrl; + break; + + case 'settings-banner': + settingsBannerPreviewImage.src = imageUrl; + settingsBannerUrl.textContent = filename; + settingsBannerContainer.classList.remove('d-none'); + settingsBannerEmpty.classList.add('d-none'); + settingsBannerImageURL = imageUrl; + break; } } @@ -592,28 +1080,32 @@ document.addEventListener('DOMContentLoaded', () => { async function createBot() { console.clear(); console.log('Creating new bot...'); - + // Get form values const name = document.getElementById('botName').value.trim(); const displayName = document.getElementById('botDisplayName').value.trim(); const bio = document.getElementById('botBio').value.trim(); const nip05 = document.getElementById('botNip05').value.trim(); + const website = document.getElementById('botWebsite')?.value.trim() || ''; // New field const keyChoice = keyOption ? keyOption.value : 'generate'; // fallback - + // Validate form if (!name) { alert('Bot name is required.'); return; } - - // Build request data + + // Build request data with added profile/banner fields const requestData = { name: name, display_name: displayName, bio: bio, - nip05: nip05 + nip05: nip05, + website: website, // Add website field + profile_picture: profileImageURL, // Add profile image URL + banner: bannerImageURL // Add banner image URL }; - + // If user selected "import", grab the NSEC key if (keyChoice === 'import') { const nsecKey = document.getElementById('botNsecKey').value.trim(); @@ -626,9 +1118,9 @@ document.addEventListener('DOMContentLoaded', () => { } else { console.log('Using auto-generated keypair'); } - + console.log('Sending request to create bot...'); - + try { const token = localStorage.getItem('authToken'); const response = await fetch(`${API_ENDPOINT}/api/bots`, { @@ -639,25 +1131,31 @@ document.addEventListener('DOMContentLoaded', () => { }, body: JSON.stringify(requestData) }); - + console.log('Response status:', response.status); const rawResponse = await response.text(); console.log('Response text:', rawResponse.substring(0, 200) + '...'); - + let jsonResponse; try { jsonResponse = JSON.parse(rawResponse); } catch (e) { console.error('Failed to parse JSON response:', e); } - + if (response.ok) { console.log('Bot created successfully!'); + // Hide modal if (typeof bootstrap !== 'undefined') { const modal = bootstrap.Modal.getInstance(createBotModalEl); if (modal) modal.hide(); } + + // Reset image URLs + profileImageURL = ''; + bannerImageURL = ''; + // Refresh bot list fetchBots(); } else { @@ -725,93 +1223,194 @@ document.addEventListener('DOMContentLoaded', () => { /* ---------------------------------------------------- * Settings Window * -------------------------------------------------- */ - window.openBotSettings = async function (botId) { + window.openBotSettings = async function(botId) { try { const token = localStorage.getItem('authToken'); const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, { headers: { 'Authorization': token } }); + if (!response.ok) { throw new Error('Failed to load bot data'); } + const bot = await response.json(); - + // Fill form fields document.getElementById('botSettingsName').value = bot.name || ''; document.getElementById('botSettingsDisplayName').value = bot.display_name || ''; document.getElementById('botSettingsBio').value = bot.bio || ''; document.getElementById('botSettingsNip05').value = bot.nip05 || ''; document.getElementById('botSettingsZap').value = bot.zap_address || ''; - + + // Add website field if it exists + const websiteField = document.getElementById('botSettingsWebsite'); + if (websiteField) { + websiteField.value = bot.website || ''; + } + + // Fill image fields + if (settingsProfilePictureContainer && settingsProfilePictureEmpty && + settingsProfilePreviewImage && settingsProfilePictureUrl) { + + if (bot.profile_picture) { + settingsProfilePreviewImage.src = bot.profile_picture; + settingsProfilePictureUrl.textContent = 'Current profile picture'; + settingsProfilePictureContainer.classList.remove('d-none'); + settingsProfilePictureEmpty.classList.add('d-none'); + settingsProfileImageURL = bot.profile_picture; + } else { + settingsProfilePictureContainer.classList.add('d-none'); + settingsProfilePictureEmpty.classList.remove('d-none'); + settingsProfileImageURL = ''; + } + } + + // Banner + if (settingsBannerContainer && settingsBannerEmpty && + settingsBannerPreviewImage && settingsBannerUrl) { + + if (bot.banner) { + settingsBannerPreviewImage.src = bot.banner; + settingsBannerUrl.textContent = 'Current banner'; + settingsBannerContainer.classList.remove('d-none'); + settingsBannerEmpty.classList.add('d-none'); + settingsBannerImageURL = bot.banner; + } else { + settingsBannerContainer.classList.add('d-none'); + settingsBannerEmpty.classList.remove('d-none'); + settingsBannerImageURL = ''; + } + } + // If post_config is present if (bot.post_config) { document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60; - - // hashtags is stored as a JSON string - const hashtagsJson = bot.post_config.hashtags || '[]'; - const tagsArr = JSON.parse(hashtagsJson); - document.getElementById('botSettingsHashtags').value = tagsArr.join(', '); + + // Parse hashtags + try { + const hashtags = JSON.parse(bot.post_config.hashtags || '[]'); + document.getElementById('botSettingsHashtags').value = hashtags.join(', '); + } catch (e) { + console.error('Failed to parse hashtags', e); + document.getElementById('botSettingsHashtags').value = ''; + } } - + // Show the modal - const modalEl = document.getElementById('botSettingsModal'); - const modal = bootstrap.Modal.getOrCreateInstance(modalEl); - modal.show(); - - // Store bot ID so we know which bot to save + if (typeof bootstrap !== 'undefined' && botSettingsModalEl) { + if (!botSettingsModal) { + botSettingsModal = new bootstrap.Modal(botSettingsModalEl); + } + botSettingsModal.show(); + } else { + console.error('Bootstrap or modal element not found'); + alert('Could not open settings modal. Please check the console for errors.'); + } + + // Store bot ID for saving document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId); - + } catch (err) { - console.error('Error loading bot data:', err); + console.error('Error loading bot settings:', err); alert('Error loading bot: ' + err.message); } }; - + + // saveBotSettings function to include website and images window.saveBotSettings = async function () { const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id'); if (!botId) { return alert('No bot ID found'); } - + // Basic info const name = document.getElementById('botSettingsName').value.trim(); const displayName = document.getElementById('botSettingsDisplayName').value.trim(); const bio = document.getElementById('botSettingsBio').value.trim(); const nip05 = document.getElementById('botSettingsNip05').value.trim(); const zap = document.getElementById('botSettingsZap').value.trim(); - + + // Get website field if it exists + const websiteField = document.getElementById('botSettingsWebsite'); + const website = websiteField ? websiteField.value.trim() : ''; + + // Validate required fields + if (!name) { + alert('Bot name is required'); + return; + } + + // Log the data we're about to send + console.log('Saving bot settings:', { + botId, + name, + display_name: displayName, + bio, + nip05, + zap_address: zap, + website, + profile_picture: settingsProfileImageURL, + banner: settingsBannerImageURL + }); + // 1) Update the basic fields (PUT /api/bots/:id) try { const token = localStorage.getItem('authToken'); + + // Prepare the request payload + const botData = { + name, + display_name: displayName, + bio, + nip05, + zap_address: zap + }; + + // Only add these fields if they have values + if (website) { + botData.website = website; + } + + if (settingsProfileImageURL) { + botData.profile_picture = settingsProfileImageURL; + } + + if (settingsBannerImageURL) { + botData.banner = settingsBannerImageURL; + } + const updateResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': token }, - body: JSON.stringify({ - name, - display_name: displayName, - bio, - nip05, - zap_address: zap - }) + body: JSON.stringify(botData) }); + if (!updateResp.ok) { - const errData = await updateResp.json().catch(() => ({})); - throw new Error(errData.error || 'Failed to update bot info'); + // Attempt to get detailed error message + let errorMessage = 'Failed to update bot info'; + try { + const errorData = await updateResp.json(); + errorMessage = errorData.error || errorMessage; + } catch (e) { + console.error('Failed to parse error response', e); + } + throw new Error(errorMessage); } } catch (err) { console.error('Failed to update bot info:', err); alert('Error updating bot info: ' + err.message); return; } - + // 2) Update the post config (PUT /api/bots/:id/config) const intervalValue = parseInt(document.getElementById('botSettingsInterval').value, 10) || 60; const hashtagsStr = document.getElementById('botSettingsHashtags').value.trim(); const hashtagsArr = hashtagsStr.length ? hashtagsStr.split(',').map(s => s.trim()) : []; - + const configPayload = { post_config: { interval_minutes: intervalValue, @@ -819,7 +1418,7 @@ document.addEventListener('DOMContentLoaded', () => { // We do not override 'enabled' here, so it remains as is } }; - + try { const token = localStorage.getItem('authToken'); const configResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}/config`, { @@ -839,18 +1438,88 @@ document.addEventListener('DOMContentLoaded', () => { alert('Error updating post config: ' + err.message); return; } - + alert('Bot settings updated!'); - + // Hide modal const modalEl = document.getElementById('botSettingsModal'); const modal = bootstrap.Modal.getInstance(modalEl); if (modal) modal.hide(); - + // Reload bots fetchBots(); }; + async function addGlobalRelay() { + const url = document.getElementById('globalRelayURL').value.trim(); + const read = document.getElementById('globalRelayRead').checked; + const write = document.getElementById('globalRelayWrite').checked; + + if (!url) { + alert('Relay URL is required'); + return; + } + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`${API_ENDPOINT}/api/global-relays`, { + method: 'POST', + headers: { + 'Authorization': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url: url, + read: read, + write: write + }) + }); + + if (response.ok) { + // Close the modal + if (globalRelayModal) { + globalRelayModal.hide(); + } + + // Refresh the list + fetchGlobalRelays(); + } else { + const errData = await response.json().catch(() => ({})); + alert(`Failed to add relay: ${errData.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Error adding global relay:', error); + alert(`Error adding relay: ${error.message}`); + } + } + + // Delete global relay + window.deleteGlobalRelay = async function(relayId) { + if (!confirm('Are you sure you want to delete this relay?')) { + return; + } + + try { + const token = localStorage.getItem('authToken'); + const response = await fetch(`${API_ENDPOINT}/api/global-relays/${relayId}`, { + method: 'DELETE', + headers: { + 'Authorization': token + } + }); + + if (response.ok) { + fetchGlobalRelays(); + } else { + const errData = await response.json().catch(() => ({})); + alert(`Failed to delete relay: ${errData.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Error deleting global relay:', error); + alert(`Error deleting relay: ${error.message}`); + } + }; + /* ---------------------------------------------------- * Nuke bot * -------------------------------------------------- */ diff --git a/web/index.html b/web/index.html index c8269c7..cbb731c 100644 --- a/web/index.html +++ b/web/index.html @@ -12,7 +12,7 @@ + @@ -138,6 +138,106 @@ +
+
+

+ + + + Global Relays +

+ +
+ +
+
+
+ + + + Global relays are used by all your bots in addition to their individual relays. This follows + the + NIP-65 + Outbox Model. +
+ +
+ +
+

Loading global relays...

+
+
+
+
+
+ + + @@ -169,6 +269,61 @@ +
+
+ +
+ + +
+
+ Profile preview +
+ + +
+
+
Select a profile picture for your bot +
+
+ +
+ +
+ + +
+
+ Banner preview +
+ + +
+
+
Select a banner image for your bot
+
@@ -256,8 +418,87 @@
-
+
+ + +
Optional. Website URL for the bot.
+
+
+ +
+ + +
+
+
No profile picture + set
+
+ Profile preview +
+ + +
+
+
+
+ +
+ +
+ + +
+
+
No banner image set
+
+ Banner preview +
+ + +
+
+
+
+ +
+ +
+ +
Publish this bot's profile to Nostr relays (must be done after + changes)
+