V1. need to add commenting and a post feed for bots.

This commit is contained in:
Enki 2025-05-03 00:56:20 -07:00
parent f3680ef30c
commit 341ffbc33d
17 changed files with 2485 additions and 556 deletions

243
CLAUDE.md Normal file
View File

@ -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

View File

@ -98,9 +98,13 @@ func main() {
// Initialize relay manager // Initialize relay manager
relayManager := relay.NewManager(logger) relayManager := relay.NewManager(logger)
// Initialize global relay service
globalRelayService := api.NewGlobalRelayService(database, logger)
// Initialize media preparation manager // Initialize media preparation manager
mediaPrep := prepare.NewManager(logger) mediaPrep := prepare.NewManager(logger)
// Initialize uploaders // Initialize uploaders
// NIP-94 uploader // NIP-94 uploader
nip94Uploader := nip94.NewUploader( nip94Uploader := nip94.NewUploader(
@ -109,12 +113,21 @@ func main() {
nil, // Supported types will be discovered nil, // Supported types will be discovered
logger, logger,
func(url, method string, payload []byte) (string, error) { func(url, method string, payload []byte) (string, error) {
// Replace with a valid bot's public key. // Get an active bot from the database
botPubkey := "your_valid_bot_pubkey_here" var bot struct {
privkey, err := keyStore.GetPrivateKey(botPubkey) Pubkey string
if err != nil {
return "", err
} }
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) return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
}, },
) )
@ -124,12 +137,21 @@ func main() {
cfg.Media.Blossom.ServerURL, cfg.Media.Blossom.ServerURL,
logger, logger,
func(url, method string) (string, error) { func(url, method string) (string, error) {
// Replace with the appropriate bot's public key // Get an active bot from the database
botPubkey := "your_valid_bot_pubkey_here" var bot struct {
privkey, err := keyStore.GetPrivateKey(botPubkey) Pubkey string
if err != nil {
return "", err
} }
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) return blossom.CreateBlossomAuthHeader(url, method, privkey)
}, },
) )
@ -148,6 +170,7 @@ func main() {
keyStore, keyStore,
eventManager, eventManager,
relayManager, relayManager,
globalRelayService,
logger, logger,
) )
@ -216,9 +239,19 @@ func main() {
content := caption + hashtagStr 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, pubkey,
content, title, // Title parameter
content, // Description parameter
mediaURL, mediaURL,
contentType, contentType,
mediaHash, mediaHash,
@ -264,8 +297,8 @@ func main() {
return relayManager.PublishEventWithEncoding(ctx, event) return relayManager.PublishEventWithEncoding(ctx, event)
} }
// Initialize scheduler with both posting functions // Initialize scheduler with both posting functions
posterScheduler := scheduler.NewScheduler( posterScheduler := scheduler.NewScheduler(
database, database,
logger, logger,
cfg.Bot.ContentDir, cfg.Bot.ContentDir,
@ -273,8 +306,10 @@ func main() {
nip94Uploader, nip94Uploader,
blossomUploader, blossomUploader,
postContentFunc, postContentFunc,
postContentEncodedFunc, // New encoded function postContentEncodedFunc,
) globalRelayService,
keyStore,
)
// Initialize API // Initialize API
apiServer := api.NewAPI( apiServer := api.NewAPI(
@ -282,6 +317,7 @@ func main() {
botService, botService,
authService, authService,
posterScheduler, posterScheduler,
globalRelayService,
) )
// Start the scheduler // Start the scheduler

View File

@ -7,7 +7,7 @@ bot:
keys_file: "./keys.json" keys_file: "./keys.json"
content_dir: "./content" content_dir: "./content"
archive_dir: "./archive" archive_dir: "./archive"
default_interval: 240 # minutes default_interval: 60 # minutes
db: db:
path: "./nostr-poster.db" path: "./nostr-poster.db"
@ -15,10 +15,10 @@ db:
media: media:
default_service: "blossom" default_service: "blossom"
nip94: nip94:
server_url: "https://files.sovbit.host" server_url: "https://files.sovbit.host" # NIP-96 servers use the base URL
require_auth: true require_auth: true
blossom: blossom:
server_url: "https://cdn.sovbit.host" server_url: "https://cdn.sovbit.host/upload" # Must include '/upload' endpoint for BUD-02 compliance
relays: relays:
- url: "wss://freelay.sovbit.host" - url: "wss://freelay.sovbit.host"

View File

@ -23,6 +23,7 @@ type BotService struct {
eventMgr *events.EventManager eventMgr *events.EventManager
relayMgr *relay.Manager relayMgr *relay.Manager
logger *zap.Logger logger *zap.Logger
globalRelayService *GlobalRelayService
} }
// NewBotService creates a new BotService // NewBotService creates a new BotService
@ -31,6 +32,7 @@ func NewBotService(
keyStore *crypto.KeyStore, keyStore *crypto.KeyStore,
eventMgr *events.EventManager, eventMgr *events.EventManager,
relayMgr *relay.Manager, relayMgr *relay.Manager,
globalRelayService *GlobalRelayService,
logger *zap.Logger, logger *zap.Logger,
) *BotService { ) *BotService {
return &BotService{ return &BotService{
@ -38,10 +40,12 @@ func NewBotService(
keyStore: keyStore, keyStore: keyStore,
eventMgr: eventMgr, eventMgr: eventMgr,
relayMgr: relayMgr, relayMgr: relayMgr,
globalRelayService: globalRelayService,
logger: logger, logger: logger,
} }
} }
// GetPrivateKey returns the private key for the given pubkey from the keystore. // GetPrivateKey returns the private key for the given pubkey from the keystore.
func (s *BotService) GetPrivateKey(pubkey string) (string, error) { func (s *BotService) GetPrivateKey(pubkey string) (string, error) {
return s.keyStore.GetPrivateKey(pubkey) return s.keyStore.GetPrivateKey(pubkey)
@ -248,8 +252,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
Read bool Read bool
Write bool Write bool
}{ }{
{"wss://relay.damus.io", true, true}, {"wss://freelay.sovbit.host", true, true},
{"wss://nostr.mutinywallet.com", true, true},
{"wss://relay.nostr.band", 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.PostConfig = postConfig
bot.MediaConfig = mediaConfig bot.MediaConfig = mediaConfig
bot.Relays = []*models.Relay{ bot.Relays = []*models.Relay{
{BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true}, {BotID: botID, URL: "wss://freelay.sovbit.host", Read: true, Write: true},
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
{BotID: botID, URL: "wss://relay.nostr.band", 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) 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 // We don't update the pubkey or encrypted_privkey
query := ` query := `
UPDATE bots SET UPDATE bots SET
@ -308,14 +322,24 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
nip05 = ?, nip05 = ?,
zap_address = ?, zap_address = ?,
profile_picture = ?, profile_picture = ?,
banner = ? banner = ?,
website = ?
WHERE id = ? AND owner_pubkey = ? 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( _, err = s.db.Exec(
query, query,
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05, bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
bot.ZapAddress, bot.ProfilePicture, bot.Banner, bot.ZapAddress, bot.ProfilePicture, bot.Banner,
websiteVal,
bot.ID, bot.OwnerPubkey, bot.ID, bot.OwnerPubkey,
) )
if err != nil { if err != nil {
@ -512,8 +536,17 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
return fmt.Errorf("failed to create metadata event: %w", err) return fmt.Errorf("failed to create metadata event: %w", 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
}
// Set up relay connections // Set up relay connections
for _, relay := range bot.Relays { for _, relay := range combinedRelays {
if relay.Write { if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay", s.logger.Warn("Failed to add relay",
@ -553,8 +586,17 @@ func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey stri
return nil, fmt.Errorf("failed to create metadata event: %w", err) return nil, fmt.Errorf("failed to create metadata event: %w", 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
}
// Set up relay connections // Set up relay connections
for _, relay := range bot.Relays { for _, relay := range combinedRelays {
if relay.Write { if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay", s.logger.Warn("Failed to add relay",

View File

@ -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
}

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"regexp" "regexp"
"database/sql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
@ -29,6 +30,7 @@ type API struct {
botService *BotService botService *BotService
authService *auth.Service authService *auth.Service
scheduler *scheduler.Scheduler scheduler *scheduler.Scheduler
globalRelayService *GlobalRelayService
} }
// NewAPI creates a new API instance // NewAPI creates a new API instance
@ -37,6 +39,7 @@ func NewAPI(
botService *BotService, botService *BotService,
authService *auth.Service, authService *auth.Service,
scheduler *scheduler.Scheduler, scheduler *scheduler.Scheduler,
globalRelayService *GlobalRelayService, // Changed from colon to comma
) *API { ) *API {
router := gin.Default() router := gin.Default()
@ -46,6 +49,7 @@ func NewAPI(
botService: botService, botService: botService,
authService: authService, authService: authService,
scheduler: scheduler, scheduler: scheduler,
globalRelayService: globalRelayService, // Added this missing field
} }
// Set up routes // Set up routes
@ -114,6 +118,16 @@ func (a *API) setupRoutes() {
statsGroup.GET("/:botId", a.getBotStats) 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 // Serve the web UI
a.router.StaticFile("/", "./web/index.html") a.router.StaticFile("/", "./web/index.html")
a.router.StaticFile("/content.html", "./web/content.html") a.router.StaticFile("/content.html", "./web/content.html")
@ -281,21 +295,39 @@ func (a *API) updateBot(c *gin.Context) {
return return
} }
var bot models.Bot var botUpdate models.Bot
if err := c.ShouldBindJSON(&bot); err != nil { if err := c.ShouldBindJSON(&botUpdate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) a.logger.Error("Invalid bot data", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
return 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 // Set the ID and owner
bot.ID = botID botUpdate.ID = botID
bot.OwnerPubkey = pubkey 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 // Update the bot
updatedBot, err := a.botService.UpdateBot(&bot) updatedBot, err := a.botService.UpdateBot(&botUpdate)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"})
a.logger.Error("Failed to update bot", zap.Error(err)) a.logger.Error("Failed to update bot", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()})
return return
} }
@ -472,8 +504,8 @@ func (a *API) publishBotProfile(c *gin.Context) {
return return
} }
// Publish the profile // Publish the profile with NIP-19 encoding
err = a.botService.PublishBotProfile(botID, pubkey) event, err := a.botService.PublishBotProfileWithEncoding(botID, pubkey)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
a.logger.Error("Failed to publish bot profile", zap.Error(err)) 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{ c.JSON(http.StatusOK, gin.H{
"message": "Profile published successfully", "message": "Profile published successfully",
"event": event,
}) })
} }
@ -743,6 +776,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
Filename string `json:"filename" binding:"required"` Filename string `json:"filename" binding:"required"`
Service string `json:"service" binding:"required"` Service string `json:"service" binding:"required"`
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL. 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 { if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
@ -762,10 +796,18 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
// Create a new uploader instance that uses the bot's key. // Create a new uploader instance that uses the bot's key.
var uploader scheduler.MediaUploader var uploader scheduler.MediaUploader
if req.Service == "blossom" { if req.Service == "blossom" {
// Get the base Blossom server URL // Get the base Blossom server URL using a fallback chain:
serverURL := bot.MediaConfig.BlossomServerURL // 1. Request URL, 2. Bot's config URL, 3. Global config URL
if req.ServerURL != "" { serverURL := req.ServerURL
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 // Log the URL for debugging purposes
@ -792,10 +834,22 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
}, },
) )
} else if req.Service == "nip94" { } else if req.Service == "nip94" {
serverURL := bot.MediaConfig.Nip94ServerURL // Similar fallback chain for NIP-94
if req.ServerURL != "" { serverURL := req.ServerURL
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( uploader = nip94.NewUploader(
serverURL, serverURL,
"", // Download URL will be discovered "", // Download URL will be discovered
@ -814,9 +868,21 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
return return
} }
// Create appropriate caption and alt text for profile images
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename)) caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
altText := caption 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) mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
if err != nil { if err != nil {
a.logger.Error("Failed to upload to media server", a.logger.Error("Failed to upload to media server",
@ -837,8 +903,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
// Updated createManualPost function in routes.go // Updated createManualPost function in routes.go
func (a *API) createManualPost(c *gin.Context) { func (a *API) createManualPost(c *gin.Context) {
pubkey := c.GetString("pubkey") pubkey := c.GetString("pubkey")
botIDStr := c.Param("id") botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
botID, err := strconv.ParseInt(botIDStr, 10, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
return return
@ -856,7 +921,7 @@ func (a *API) createManualPost(c *gin.Context) {
Kind int `json:"kind" binding:"required"` Kind int `json:"kind" binding:"required"`
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
Title string `json:"title"` Title string `json:"title"`
Alt string `json:"alt"` // Added to support alt text for images Alt string `json:"alt"`
Hashtags []string `json:"hashtags"` Hashtags []string `json:"hashtags"`
} }
@ -882,8 +947,6 @@ func (a *API) createManualPost(c *gin.Context) {
if len(matches) > 0 { if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found mediaURL = matches[0] // Use the first URL found
// Try to determine media type
mediaType = inferMediaTypeFromURL(mediaURL) mediaType = inferMediaTypeFromURL(mediaURL)
} }
@ -894,7 +957,6 @@ func (a *API) createManualPost(c *gin.Context) {
switch req.Kind { switch req.Kind {
case 1: case 1:
// Standard text note // Standard text note
// Create tags
var tags []nostr.Tag var tags []nostr.Tag
for _, tag := range req.Hashtags { for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag}) tags = append(tags, nostr.Tag{"t", tag})
@ -910,7 +972,7 @@ func (a *API) createManualPost(c *gin.Context) {
mediaURL, mediaURL,
mediaType, mediaType,
mediaHash, mediaHash,
req.Alt, // Use the alt text if provided req.Alt,
req.Hashtags, req.Hashtags,
) )
default: default:
@ -924,8 +986,17 @@ func (a *API) createManualPost(c *gin.Context) {
return return
} }
// Configure relay manager // Get combined relays (bot + global)
for _, relay := range bot.Relays { combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey)
if err != nil {
a.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Configure relay manager with combined relays
for _, relay := range combinedRelays {
if relay.Write { if relay.Write {
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
a.logger.Warn("Failed to add relay", a.logger.Warn("Failed to add relay",
@ -1000,3 +1071,101 @@ func extractHashtags(tags []nostr.Tag) []string {
} }
return hashtags 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)
}

View File

@ -33,11 +33,12 @@ func New(dbPath string) (*DB, error) {
db.MustExec("PRAGMA foreign_keys=ON;") db.MustExec("PRAGMA foreign_keys=ON;")
return &DB{db}, nil return &DB{db}, nil
} }
// Initialize creates the database schema if it doesn't exist // Initialize creates the database schema if it doesn't exist
func (db *DB) Initialize() error { func (db *DB) Initialize() error {
// Create bots table // Create bots table with updated fields
_, err := db.Exec(` _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS bots ( CREATE TABLE IF NOT EXISTS bots (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -50,6 +51,7 @@ func (db *DB) Initialize() error {
zap_address TEXT, zap_address TEXT,
profile_picture TEXT, profile_picture TEXT,
banner TEXT, banner TEXT,
website TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
owner_pubkey TEXT NOT NULL owner_pubkey TEXT NOT NULL
)`) )`)
@ -57,6 +59,24 @@ func (db *DB) Initialize() error {
return fmt.Errorf("failed to create bots table: %w", err) 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 // Create post_config table
_, err = db.Exec(` _, err = db.Exec(`
CREATE TABLE IF NOT EXISTS post_config ( 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) 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 return nil
} }

View File

@ -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 // 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) { 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 // Log information about the upload
u.logger.Info("Uploading file to Blossom server", u.logger.Info("Uploading file to Blossom server",
zap.String("filePath", filePath), zap.String("filePath", filePath),
zap.String("serverURL", u.serverURL)) zap.String("serverURL", serverURL))
// Open the file // Open the file
file, err := os.Open(filePath) 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 // 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 // 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 { if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err) 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 // Add authorization header if available
if u.getAuthHeader != nil { if u.getAuthHeader != nil {
authHeader, err := u.getAuthHeader(u.serverURL, "PUT") authHeader, err := u.getAuthHeader(serverURL, "PUT")
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to create auth header: %w", err) 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 && if resp.StatusCode != http.StatusOK &&
resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusCreated &&
resp.StatusCode != http.StatusAccepted { 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 // Parse response
var blossomResp BlossomResponse var blossomResp BlossomResponse
if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil { 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 // 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) 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 // Log the successful response
u.logger.Info("Upload successful", u.logger.Info("Upload successful",
zap.String("url", blossomResp.URL), 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 // DeleteFile deletes a file from the Blossom server
func (u *Uploader) DeleteFile(fileHash string) error { 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 // 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 // Create the request
req, err := http.NewRequest("DELETE", deleteURL, nil) req, err := http.NewRequest("DELETE", deleteURL, nil)
@ -299,12 +350,33 @@ func (u *Uploader) DeleteFile(fileHash string) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Check response status // Read response for better error reporting
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes)) 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 && 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 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. // WithCustomURL creates a new uploader instance with the specified custom URL.
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { 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{ return &Uploader{
serverURL: customURL, serverURL: customURL,
logger: u.logger, logger: u.logger,
getAuthHeader: u.getAuthHeader, getAuthHeader: u.getAuthHeader,
} }
} }
// GetServerURL returns the server URL
func (u *Uploader) GetServerURL() string {
return u.serverURL
}

View File

@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/nbd-wtf/go-nostr" "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 // UploadFile uploads a file to a NIP-96 compatible server
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) { 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 // Open the file
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@ -197,7 +209,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
} }
// Create the request // Create the request
req, err := http.NewRequest("POST", u.serverURL, &requestBody) req, err := http.NewRequest("POST", serverURL, &requestBody)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err) 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) bodyHash := bodyHasher.Sum(nil)
// Use the body hash for authentication // Use the body hash for authentication
authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash) authHeader, err := u.getAuthHeader(serverURL, "POST", bodyHash)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to create auth header: %w", err) 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 // DeleteFile deletes a file from the server
func (u *Uploader) DeleteFile(fileHash string) error { 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 // Create the delete URL
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash) deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash)
// Create the request // Create the request
req, err := http.NewRequest("DELETE", deleteURL, nil) 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 // WithCustomURL creates a new uploader instance with the specified custom URL
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader { 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 // Create a new uploader with the same configuration but a different URL
return &Uploader{ return &Uploader{
serverURL: customURL, serverURL: customURL,
@ -484,3 +513,8 @@ func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
getAuthHeader: u.getAuthHeader, getAuthHeader: u.getAuthHeader,
} }
} }
// GetServerURL returns the server URL
func (u *Uploader) GetServerURL() string {
return u.serverURL
}

View File

@ -1,7 +1,9 @@
// internal/models/bot.go
package models package models
import ( import (
"database/sql"
"encoding/json"
"fmt"
"time" "time"
) )
@ -17,6 +19,8 @@ type Bot struct {
ZapAddress string `db:"zap_address" json:"zap_address"` ZapAddress string `db:"zap_address" json:"zap_address"`
ProfilePicture string `db:"profile_picture" json:"profile_picture"` ProfilePicture string `db:"profile_picture" json:"profile_picture"`
Banner string `db:"banner" json:"banner"` 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"` CreatedAt time.Time `db:"created_at" json:"created_at"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"` OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
@ -53,6 +57,7 @@ type Relay struct {
URL string `db:"url" json:"url"` URL string `db:"url" json:"url"`
Read bool `db:"read" json:"read"` Read bool `db:"read" json:"read"`
Write bool `db:"write" json:"write"` 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 // Post represents a post made by the bot
@ -66,3 +71,67 @@ type Post struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
Error string `db:"error" json:"error,omitempty"` Error string `db:"error" json:"error,omitempty"`
} }
// 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
}

View File

@ -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 // CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot
func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) { 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{}{ metadata := map[string]interface{}{
"name": bot.Name, "name": bot.Name,
"display_name": bot.DisplayName, "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 != "" { if bot.Nip05 != "" {
metadata["nip05"] = bot.Nip05 metadata["nip05"] = bot.Nip05
} }
@ -47,6 +47,15 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
metadata["banner"] = 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 // Convert metadata to JSON
content, err := json.Marshal(metadata) content, err := json.Marshal(metadata)
if err != nil { if err != nil {
@ -55,7 +64,7 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
// Create the event // Create the event
ev := nostr.Event{ ev := nostr.Event{
Kind: 0, Kind: 0, // User metadata event
CreatedAt: nostr.Timestamp(time.Now().Unix()), CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: []nostr.Tag{}, Tags: []nostr.Tag{},
Content: string(content), Content: string(content),

View File

@ -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
}

View File

@ -98,7 +98,12 @@ func (p *Poster) PostContent(
// Determine the appropriate event kind and create the event // Determine the appropriate event kind and create the event
if isImage { 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 // Create hashtag string for post content
var hashtagStr string var hashtagStr string
if len(hashtags) > 0 { if len(hashtags) > 0 {
@ -111,9 +116,11 @@ func (p *Poster) PostContent(
content := caption + hashtagStr content := caption + hashtagStr
event, err = p.eventMgr.CreateAndSignMediaEvent( // Use kind 20 (picture post) for better media compatibility
event, err = p.eventMgr.CreateAndSignPictureEvent(
pubkey, pubkey,
content, caption, // Title
content, // Description
mediaURL, mediaURL,
contentType, contentType,
mediaHash, mediaHash,

View File

@ -88,8 +88,8 @@ func (m *Manager) AddRelay(url string, read, write bool) error {
return nil return nil
} }
// Connect to the relay // Connect to the relay with a longer timeout for slow connections
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
relay, err := nostr.RelayConnect(ctx, url) relay, err := nostr.RelayConnect(ctx, url)
@ -135,9 +135,39 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
copy(writeURLs, m.writeURLs) copy(writeURLs, m.writeURLs)
m.mu.RUnlock() m.mu.RUnlock()
if len(writeURLs) == 0 {
// 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 { if len(writeURLs) == 0 {
return nil, fmt.Errorf("no write relays configured") return nil, fmt.Errorf("no write relays configured")
} }
}
// Keep track of successful publishes // Keep track of successful publishes
var successful []string var successful []string
@ -160,7 +190,7 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
} }
// Create a new context with timeout // Create a new context with timeout
publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second) publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
// Publish the event // Publish the event

View File

@ -14,9 +14,15 @@ import (
"git.sovbit.dev/Enki/nostr-poster/internal/db" "git.sovbit.dev/Enki/nostr-poster/internal/db"
"git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/utils" "git.sovbit.dev/Enki/nostr-poster/internal/utils"
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
"go.uber.org/zap" "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 // MediaUploader defines the interface for uploading media
type MediaUploader interface { type MediaUploader interface {
UploadFile(filePath string, caption string, altText string) (string, string, error) UploadFile(filePath string, caption string, altText string) (string, string, error)
@ -61,9 +67,11 @@ type Scheduler struct {
nip94Uploader MediaUploader nip94Uploader MediaUploader
blossomUploader MediaUploader blossomUploader MediaUploader
postContent ContentPoster postContent ContentPoster
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support postContentEncoded ContentPosterWithEncoding
botJobs map[int64]cron.EntryID botJobs map[int64]cron.EntryID
mu sync.RWMutex mu sync.RWMutex
globalRelayService RelayService
keyStore *crypto.KeyStore
} }
// NewScheduler creates a new content scheduler // NewScheduler creates a new content scheduler
@ -75,7 +83,9 @@ func NewScheduler(
nip94Uploader MediaUploader, nip94Uploader MediaUploader,
blossomUploader MediaUploader, blossomUploader MediaUploader,
postContent ContentPoster, postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support postContentEncoded ContentPosterWithEncoding,
globalRelayService RelayService,
keyStore *crypto.KeyStore,
) *Scheduler { ) *Scheduler {
if logger == nil { if logger == nil {
// Create a default logger // Create a default logger
@ -91,15 +101,17 @@ func NewScheduler(
return &Scheduler{ return &Scheduler{
db: db, db: db,
cron: cronScheduler, cron: cronScheduler, // Use the local variable
logger: logger, logger: logger,
contentDir: contentDir, contentDir: contentDir,
archiveDir: archiveDir, archiveDir: archiveDir,
nip94Uploader: nip94Uploader, nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader, blossomUploader: blossomUploader,
postContent: postContent, postContent: postContent,
postContentEncoded: postContentEncoded, // Initialize the new field postContentEncoded: postContentEncoded,
globalRelayService: globalRelayService,
botJobs: make(map[int64]cron.EntryID), botJobs: make(map[int64]cron.EntryID),
keyStore: keyStore,
} }
} }
@ -107,7 +119,8 @@ func NewScheduler(
func (s *Scheduler) Start() error { func (s *Scheduler) Start() error {
// Load all bots with enabled post configs // Load all bots with enabled post configs
query := ` query := `
SELECT b.*, pc.*, mc.* 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 FROM bots b
JOIN post_config pc ON b.id = pc.bot_id JOIN post_config pc ON b.id = pc.bot_id
JOIN media_config mc ON b.id = mc.bot_id JOIN media_config mc ON b.id = mc.bot_id
@ -126,15 +139,11 @@ func (s *Scheduler) Start() error {
var postConfig models.PostConfig var postConfig models.PostConfig
var mediaConfig models.MediaConfig var mediaConfig models.MediaConfig
// Map the results to our structs
err := rows.Scan( err := rows.Scan(
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName, &bot.ID, &bot.Pubkey, &bot.Name, &bot.DisplayName,
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner, &postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags,
&bot.CreatedAt, &bot.OwnerPubkey, &mediaConfig.PrimaryService, &mediaConfig.FallbackService,
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
&postConfig.PostTemplate, &postConfig.Enabled,
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
) )
if err != nil { if err != nil {
s.logger.Error("Failed to scan bot row", zap.Error(err)) s.logger.Error("Failed to scan bot row", zap.Error(err))
@ -230,7 +239,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
return return
} }
// Select the appropriate uploader // Get the appropriate uploader for this bot
var uploader MediaUploader var uploader MediaUploader
if bot.MediaConfig.PrimaryService == "blossom" { if bot.MediaConfig.PrimaryService == "blossom" {
uploader = s.blossomUploader uploader = s.blossomUploader
@ -276,7 +285,32 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
filename := filepath.Base(contentPath) filename := filepath.Base(contentPath)
caption := strings.TrimSuffix(filename, filepath.Ext(filename)) 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 var postErr error
if s.postContentEncoded != nil { if s.postContentEncoded != nil {
@ -449,3 +483,12 @@ func (s *Scheduler) RunNow(botID int64) error {
return nil return nil
} }
func containsRelay(relays []*models.Relay, url string) bool {
for _, relay := range relays {
if relay.URL == url {
return true
}
}
return false
}

View File

@ -23,6 +23,46 @@ document.addEventListener('DOMContentLoaded', () => {
// (IDs must match the modals in your HTML) // (IDs must match the modals in your HTML)
const botSettingsModalEl = document.getElementById('botSettingsModal'); 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 * Bootstrap Modal instance
* -------------------------------------------------- */ * -------------------------------------------------- */
@ -37,11 +77,17 @@ document.addEventListener('DOMContentLoaded', () => {
botSettingsModal = new bootstrap.Modal(botSettingsModalEl); botSettingsModal = new bootstrap.Modal(botSettingsModalEl);
} }
let profileImageURL = '';
let bannerImageURL = '';
let settingsProfileImageURL = '';
let settingsBannerImageURL = '';
let globalRelays = [];
let globalRelayModal;
/* ---------------------------------------------------- /* ----------------------------------------------------
* Global State * Global State
* -------------------------------------------------- */ * -------------------------------------------------- */
let currentUser = null; 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 * On page load, check if already logged in
@ -54,6 +100,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (loginButton) loginButton.addEventListener('click', login); if (loginButton) loginButton.addEventListener('click', login);
if (logoutButton) logoutButton.addEventListener('click', logout); 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 (createBotBtn) createBotBtn.addEventListener('click', showCreateBotModal);
if (saveBotBtn) saveBotBtn.addEventListener('click', createBot); if (saveBotBtn) saveBotBtn.addEventListener('click', createBot);
@ -136,10 +185,57 @@ 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 * Authentication / Login / Logout
* -------------------------------------------------- */ * -------------------------------------------------- */
async function checkAuth() { // In main.js, look at the checkAuth function
async function checkAuth() {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
if (!token) { if (!token) {
showAuthSection(); showAuthSection();
@ -153,20 +249,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// data.pubkey is currently hex
// Convert to npub using nip19 from nostr-tools // Convert to npub using nip19 from nostr-tools
const { nip19 } = window.nostrTools; const { nip19 } = window.nostrTools;
const userNpub = nip19.npubEncode(data.pubkey); const userNpub = nip19.npubEncode(data.pubkey);
// Store npub as currentUser
currentUser = userNpub; currentUser = userNpub;
showMainContent(); showMainContent();
// Always load bots if on the main page
fetchBots(); fetchBots();
fetchGlobalRelays(); // Make sure this function is called
} else { } else {
// Token invalid
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
showAuthSection(); showAuthSection();
} }
@ -174,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('Auth check failed:', error); console.error('Auth check failed:', error);
showAuthSection(); showAuthSection();
} }
} }
async function login() { async function login() {
@ -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() { function logout() {
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
currentUser = null; currentUser = null;
@ -477,6 +598,20 @@ document.addEventListener('DOMContentLoaded', () => {
} }
window.publishBotProfile = async function(botId) { 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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Publishing...';
}
try { try {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/profile/publish`, { const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/profile/publish`, {
@ -495,8 +630,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Check if event data with NIP-19 encoding is available // Check if event data with NIP-19 encoding is available
if (data.event) { if (data.event) {
// 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!');
}
} 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 // Create a modal to show the encoded event info
const modalId = 'eventInfoModal'; const modalId = 'profilePublishedModal';
// Remove any existing modal with the same ID // Remove any existing modal with the same ID
const existingModal = document.getElementById(modalId); const existingModal = document.getElementById(modalId);
@ -504,23 +660,35 @@ document.addEventListener('DOMContentLoaded', () => {
existingModal.remove(); existingModal.remove();
} }
const eventModal = document.createElement('div'); // Create the modal
eventModal.className = 'modal fade'; const modal = document.createElement('div');
eventModal.id = modalId; modal.className = 'modal fade';
eventModal.setAttribute('tabindex', '-1'); modal.id = modalId;
eventModal.innerHTML = ` modal.setAttribute('tabindex', '-1');
<div class="modal-dialog"> modal.setAttribute('aria-labelledby', `${modalId}Label`);
modal.setAttribute('aria-hidden', 'true');
// Create the modal content
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Profile Published Successfully</h5> <h5 class="modal-title" id="${modalId}Label">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-circle-fill text-success me-2" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
Profile Published Successfully
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-2"> <p>Your bot's profile has been published to Nostr relays!</p>
<div class="mt-3">
<label class="form-label">Note ID (NIP-19):</label> <label class="form-label">Note ID (NIP-19):</label>
<div class="input-group"> <div class="input-group mb-2">
<input type="text" class="form-control" value="${data.event.note || ''}" readonly> <input type="text" class="form-control font-monospace small" value="${eventData.note || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.note || ''}"> <button class="btn btn-outline-secondary copy-btn" data-value="${eventData.note || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
@ -528,11 +696,12 @@ document.addEventListener('DOMContentLoaded', () => {
</button> </button>
</div> </div>
</div> </div>
<div class="mb-2">
<label class="form-label">Event with Relays (NIP-19):</label> <div class="mt-3">
<div class="input-group"> <label class="form-label">Event with Relay Info (NIP-19):</label>
<input type="text" class="form-control" value="${data.event.nevent || ''}" readonly> <div class="input-group mb-2">
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.nevent || ''}"> <input type="text" class="form-control font-monospace small" value="${eventData.nevent || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${eventData.nevent || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/> <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/> <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
@ -540,6 +709,12 @@ document.addEventListener('DOMContentLoaded', () => {
</button> </button>
</div> </div>
</div> </div>
<div class="alert alert-info mt-3">
<small>
<strong>Tip:</strong> You can use these identifiers to share your bot's profile or look it up in Nostr clients.
</small>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
@ -549,21 +724,33 @@ document.addEventListener('DOMContentLoaded', () => {
`; `;
// Add the modal to the document // Add the modal to the document
document.body.appendChild(eventModal); document.body.appendChild(modal);
// Show the modal using Bootstrap // Show the modal
const bsModal = new bootstrap.Modal(document.getElementById(modalId)); const modalInstance = new bootstrap.Modal(document.getElementById(modalId));
bsModal.show(); 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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check text-success" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
`;
setTimeout(() => {
this.innerHTML = originalHTML;
}, 2000);
});
});
});
} }
alert('Profile published successfully!');
} catch (err) {
console.error('Error publishing profile:', err);
alert(`Error publishing profile: ${err.message}`);
}
};
/* ---------------------------------------------------- /* ----------------------------------------------------
* Show Create Bot Modal * Show Create Bot Modal
* -------------------------------------------------- */ * -------------------------------------------------- */
@ -573,6 +760,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (createBotForm) createBotForm.reset(); if (createBotForm) createBotForm.reset();
// Default: keyOption = "generate" → hide nsecKeyInput // Default: keyOption = "generate" → hide nsecKeyInput
const keyOption = document.getElementById('keyOption');
const nsecKeyInput = document.getElementById('nsecKeyInput');
if (keyOption) { if (keyOption) {
keyOption.value = 'generate'; keyOption.value = 'generate';
} }
@ -580,9 +769,308 @@ document.addEventListener('DOMContentLoaded', () => {
nsecKeyInput.style.display = 'none'; nsecKeyInput.style.display = 'none';
} }
// Show modal // Show modal - ensure modal is initialized
if (createBotModal) { if (typeof bootstrap !== 'undefined' && createBotModalEl) {
if (!createBotModal) {
createBotModal = new bootstrap.Modal(createBotModalEl);
}
createBotModal.show(); 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 = '<div class="alert alert-warning">Failed to load global relays</div>';
}
} catch (error) {
console.error('Error fetching global relays:', error);
globalRelaysList.innerHTML = '<div class="alert alert-danger">Error loading global relays</div>';
}
}
// Render global relays
function renderGlobalRelays(relays) {
if (!globalRelaysList) return;
globalRelaysList.innerHTML = '';
if (!relays || relays.length === 0) {
globalRelaysList.innerHTML = `
<div class="alert alert-info">
No global relays configured. Add a relay to get started.
</div>
`;
return;
}
let html = '<div class="list-group">';
relays.forEach(relay => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-1">${relay.url}</h5>
<span class="badge ${relay.read ? 'bg-primary' : 'bg-secondary'} me-1">Read: ${relay.read ? 'Yes' : 'No'}</span>
<span class="badge ${relay.write ? 'bg-primary' : 'bg-secondary'}">Write: ${relay.write ? 'Yes' : 'No'}</span>
</div>
<div>
<button class="btn btn-sm btn-danger" onclick="deleteGlobalRelay(${relay.id})">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 5h4a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5zm2 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 0 1h-1a.5.5 0 0 1-.5-.5z"/>
<path d="M14 3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1H0v1a2 2 0 0 0 2 2v9a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6a2 2 0 0 0 2-2V3h-2zM2.5 5h11v9a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1V5z"/>
</svg>
</button>
</div>
</div>
</div>
`;
});
html += '</div>';
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 (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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 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 = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/><path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/></svg> 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;
} }
} }
@ -598,6 +1086,7 @@ document.addEventListener('DOMContentLoaded', () => {
const displayName = document.getElementById('botDisplayName').value.trim(); const displayName = document.getElementById('botDisplayName').value.trim();
const bio = document.getElementById('botBio').value.trim(); const bio = document.getElementById('botBio').value.trim();
const nip05 = document.getElementById('botNip05').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 const keyChoice = keyOption ? keyOption.value : 'generate'; // fallback
// Validate form // Validate form
@ -606,12 +1095,15 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Build request data // Build request data with added profile/banner fields
const requestData = { const requestData = {
name: name, name: name,
display_name: displayName, display_name: displayName,
bio: bio, 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 user selected "import", grab the NSEC key
@ -653,11 +1145,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (response.ok) { if (response.ok) {
console.log('Bot created successfully!'); console.log('Bot created successfully!');
// Hide modal // Hide modal
if (typeof bootstrap !== 'undefined') { if (typeof bootstrap !== 'undefined') {
const modal = bootstrap.Modal.getInstance(createBotModalEl); const modal = bootstrap.Modal.getInstance(createBotModalEl);
if (modal) modal.hide(); if (modal) modal.hide();
} }
// Reset image URLs
profileImageURL = '';
bannerImageURL = '';
// Refresh bot list // Refresh bot list
fetchBots(); fetchBots();
} else { } else {
@ -725,15 +1223,17 @@ document.addEventListener('DOMContentLoaded', () => {
/* ---------------------------------------------------- /* ----------------------------------------------------
* Settings Window * Settings Window
* -------------------------------------------------- */ * -------------------------------------------------- */
window.openBotSettings = async function (botId) { window.openBotSettings = async function(botId) {
try { try {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, { const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
headers: { 'Authorization': token } headers: { 'Authorization': token }
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load bot data'); throw new Error('Failed to load bot data');
} }
const bot = await response.json(); const bot = await response.json();
// Fill form fields // Fill form fields
@ -743,30 +1243,81 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('botSettingsNip05').value = bot.nip05 || ''; document.getElementById('botSettingsNip05').value = bot.nip05 || '';
document.getElementById('botSettingsZap').value = bot.zap_address || ''; 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 post_config is present
if (bot.post_config) { if (bot.post_config) {
document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60; document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60;
// hashtags is stored as a JSON string // Parse hashtags
const hashtagsJson = bot.post_config.hashtags || '[]'; try {
const tagsArr = JSON.parse(hashtagsJson); const hashtags = JSON.parse(bot.post_config.hashtags || '[]');
document.getElementById('botSettingsHashtags').value = tagsArr.join(', '); document.getElementById('botSettingsHashtags').value = hashtags.join(', ');
} catch (e) {
console.error('Failed to parse hashtags', e);
document.getElementById('botSettingsHashtags').value = '';
}
} }
// Show the modal // Show the modal
const modalEl = document.getElementById('botSettingsModal'); if (typeof bootstrap !== 'undefined' && botSettingsModalEl) {
const modal = bootstrap.Modal.getOrCreateInstance(modalEl); if (!botSettingsModal) {
modal.show(); 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 so we know which bot to save // Store bot ID for saving
document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId); document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId);
} catch (err) { } catch (err) {
console.error('Error loading bot data:', err); console.error('Error loading bot settings:', err);
alert('Error loading bot: ' + err.message); alert('Error loading bot: ' + err.message);
} }
}; };
// saveBotSettings function to include website and images
window.saveBotSettings = async function () { window.saveBotSettings = async function () {
const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id'); const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id');
if (!botId) { if (!botId) {
@ -780,26 +1331,74 @@ document.addEventListener('DOMContentLoaded', () => {
const nip05 = document.getElementById('botSettingsNip05').value.trim(); const nip05 = document.getElementById('botSettingsNip05').value.trim();
const zap = document.getElementById('botSettingsZap').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) // 1) Update the basic fields (PUT /api/bots/:id)
try { try {
const token = localStorage.getItem('authToken'); 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}`, { const updateResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': token 'Authorization': token
}, },
body: JSON.stringify({ body: JSON.stringify(botData)
name,
display_name: displayName,
bio,
nip05,
zap_address: zap
})
}); });
if (!updateResp.ok) { if (!updateResp.ok) {
const errData = await updateResp.json().catch(() => ({})); // Attempt to get detailed error message
throw new Error(errData.error || 'Failed to update bot info'); 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) { } catch (err) {
console.error('Failed to update bot info:', err); console.error('Failed to update bot info:', err);
@ -851,6 +1450,76 @@ document.addEventListener('DOMContentLoaded', () => {
fetchBots(); 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 * Nuke bot
* -------------------------------------------------- */ * -------------------------------------------------- */

View File

@ -138,6 +138,106 @@
<!-- Bot cards will be rendered here --> <!-- Bot cards will be rendered here -->
</div> </div>
</div> </div>
<div id="global-relays-section" class="mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="#9370DB"
class="bi bi-broadcast me-2" viewBox="0 0 16 16">
<path
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM10 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
</svg>
Global Relays
</h2>
<button id="add-global-relay-btn" class="btn btn-success">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
</svg>
Add Global Relay
</button>
</div>
<div class="card">
<div class="card-body">
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-info-circle-fill me-2" viewBox="0 0 16 16">
<path
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" />
</svg>
Global relays are used by all your bots in addition to their individual relays. This follows
the
<a href="https://github.com/nostr-protocol/nips/blob/master/65.md" target="_blank">NIP-65
Outbox Model</a>.
</div>
<div id="global-relays-list" class="mt-4">
<!-- Relays will be rendered here -->
<div class="text-center py-4 text-muted">
<p>Loading global relays...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Add Global Relay Modal -->
<div class="modal fade" id="globalRelayModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#9370DB"
class="bi bi-broadcast me-2" viewBox="0 0 16 16">
<path
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM10 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
</svg>
Add Global Relay
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="global-relay-form">
<div class="mb-3">
<label for="globalRelayURL" class="form-label">Relay URL</label>
<input type="url" class="form-control" id="globalRelayURL" required
placeholder="wss://relay.example.com">
<div class="form-text">Enter the WebSocket URL of the relay</div>
</div>
<div class="mb-3">
<label class="form-label">Access Type</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="globalRelayRead" checked>
<label class="form-check-label" for="globalRelayRead">
Read (receive posts from this relay)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="globalRelayWrite" checked>
<label class="form-check-label" for="globalRelayWrite">
Write (send posts to this relay)
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="save-global-relay-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
</svg>
Add Relay
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -169,6 +269,61 @@
<input type="text" class="form-control" id="botDisplayName" <input type="text" class="form-control" id="botDisplayName"
placeholder="e.g., My Art Posting Bot"> placeholder="e.g., My Art Posting Bot">
</div> </div>
<hr class="border-secondary">
<div class="mb-3">
<label class="form-label">Profile Picture</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="botProfilePicture" accept="image/*">
<button class="btn btn-secondary" type="button" id="uploadProfilePicture">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
<path
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
</svg>
Upload
</button>
</div>
<div id="profilePicturePreview" class="d-none mt-2">
<img id="profilePreviewImage" class="img-thumbnail" style="max-height: 150px;"
alt="Profile preview">
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-danger"
id="removeProfilePicture">Remove</button>
<span id="profilePictureUrl" class="ms-2 text-muted small"></span>
</div>
</div>
<div class="form-text">Select a profile picture for your bot
</div>
</div>
<div class="mb-3">
<label class="form-label">Banner Image</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="botBanner" accept="image/*">
<button class="btn btn-secondary" type="button" id="uploadBanner">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
<path
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
</svg>
Upload
</button>
</div>
<div id="bannerPreview" class="d-none mt-2">
<img id="bannerPreviewImage" class="img-thumbnail"
style="max-width: 100%; max-height: 100px;" alt="Banner preview">
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-danger"
id="removeBanner">Remove</button>
<span id="bannerUrl" class="ms-2 text-muted small"></span>
</div>
</div>
<div class="form-text">Select a banner image for your bot</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="botBio" class="form-label">Bio</label> <label for="botBio" class="form-label">Bio</label>
<textarea class="form-control" id="botBio" rows="3" <textarea class="form-control" id="botBio" rows="3"
@ -180,6 +335,12 @@
placeholder="e.g., bot@yourdomain.com"> placeholder="e.g., bot@yourdomain.com">
<div class="form-text">Optional. Used for verification.</div> <div class="form-text">Optional. Used for verification.</div>
</div> </div>
<div class="mb-3">
<label for="botWebsite" class="form-label">Website</label>
<input type="url" class="form-control" id="botWebsite"
placeholder="e.g., https://yourwebsite.com">
<div class="form-text">Optional. Website URL for the bot.</div>
</div>
<hr class="border-secondary"> <hr class="border-secondary">
<div class="mb-3"> <div class="mb-3">
@ -243,6 +404,7 @@
<label for="botSettingsDisplayName" class="form-label">Display Name</label> <label for="botSettingsDisplayName" class="form-label">Display Name</label>
<input type="text" class="form-control" id="botSettingsDisplayName"> <input type="text" class="form-control" id="botSettingsDisplayName">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="botSettingsBio" class="form-label">Bio</label> <label for="botSettingsBio" class="form-label">Bio</label>
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea> <textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
@ -256,8 +418,87 @@
<input type="text" class="form-control" id="botSettingsZap"> <input type="text" class="form-control" id="botSettingsZap">
</div> </div>
<hr> <div class="mb-3">
<label for="botWebsite" class="form-label">Website</label>
<input type="url" class="form-control" id="botWebsite"
placeholder="e.g., https://yourwebsite.com">
<div class="form-text">Optional. Website URL for the bot.</div>
</div>
<div class="mb-3">
<label class="form-label">Profile Picture</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="botSettingsProfilePicture" accept="image/*">
<button class="btn btn-secondary" type="button" id="uploadSettingsProfilePicture">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
<path
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
</svg>
Upload
</button>
</div>
<div id="settingsProfilePicturePreview" class="mt-2">
<div id="settingsProfilePictureEmpty" class="alert alert-secondary">No profile picture
set</div>
<div id="settingsProfilePictureContainer" class="d-none">
<img id="settingsProfilePreviewImage" class="img-thumbnail"
style="max-height: 150px;" alt="Profile preview">
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-danger"
id="removeSettingsProfilePicture">Remove</button>
<span id="settingsProfilePictureUrl" class="ms-2 text-muted small"></span>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Banner Image</label>
<div class="input-group mb-2">
<input type="file" class="form-control" id="botSettingsBanner" accept="image/*">
<button class="btn btn-secondary" type="button" id="uploadSettingsBanner">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
<path
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
</svg>
Upload
</button>
</div>
<div id="settingsBannerPreview" class="mt-2">
<div id="settingsBannerEmpty" class="alert alert-secondary">No banner image set</div>
<div id="settingsBannerContainer" class="d-none">
<img id="settingsBannerPreviewImage" class="img-thumbnail"
style="max-width: 100%; max-height: 100px;" alt="Banner preview">
<div class="mt-1">
<button type="button" class="btn btn-sm btn-outline-danger"
id="removeSettingsBanner">Remove</button>
<span id="settingsBannerUrl" class="ms-2 text-muted small"></span>
</div>
</div>
</div>
</div>
<hr>
<!-- Add Profile Publishing Button -->
<div class="mb-3 text-center">
<button type="button" class="btn btn-primary" id="publishProfileBtn"
onclick="publishBotProfile(document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id'))">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-broadcast-pin me-1" viewBox="0 0 16 16">
<path
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8z" />
</svg>
Publish Profile to Nostr
</button>
<div class="form-text">Publish this bot's profile to Nostr relays (must be done after
changes)</div>
</div>
<!-- Post config fields --> <!-- Post config fields -->
<div class="mb-3"> <div class="mb-3">
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label> <label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>