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
relayManager := relay.NewManager(logger)
// Initialize global relay service
globalRelayService := api.NewGlobalRelayService(database, logger)
// Initialize media preparation manager
mediaPrep := prepare.NewManager(logger)
// Initialize uploaders
// NIP-94 uploader
nip94Uploader := nip94.NewUploader(
@ -109,12 +113,21 @@ func main() {
nil, // Supported types will be discovered
logger,
func(url, method string, payload []byte) (string, error) {
// Replace with a valid bot's public key.
botPubkey := "your_valid_bot_pubkey_here"
privkey, err := keyStore.GetPrivateKey(botPubkey)
if err != nil {
return "", err
// Get an active bot from the database
var bot struct {
Pubkey string
}
err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1")
if err != nil {
return "", fmt.Errorf("no bots found for auth: %w", err)
}
// Get the private key for this bot
privkey, err := keyStore.GetPrivateKey(bot.Pubkey)
if err != nil {
return "", fmt.Errorf("failed to get private key: %w", err)
}
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
},
)
@ -124,12 +137,21 @@ func main() {
cfg.Media.Blossom.ServerURL,
logger,
func(url, method string) (string, error) {
// Replace with the appropriate bot's public key
botPubkey := "your_valid_bot_pubkey_here"
privkey, err := keyStore.GetPrivateKey(botPubkey)
if err != nil {
return "", err
// Get an active bot from the database
var bot struct {
Pubkey string
}
err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1")
if err != nil {
return "", fmt.Errorf("no bots found for auth: %w", err)
}
// Get the private key for this bot
privkey, err := keyStore.GetPrivateKey(bot.Pubkey)
if err != nil {
return "", fmt.Errorf("failed to get private key: %w", err)
}
return blossom.CreateBlossomAuthHeader(url, method, privkey)
},
)
@ -148,6 +170,7 @@ func main() {
keyStore,
eventManager,
relayManager,
globalRelayService,
logger,
)
@ -216,9 +239,19 @@ func main() {
content := caption + hashtagStr
event, err = eventManager.CreateAndSignMediaEvent(
// Log mediaURL for debugging
logger.Debug("Creating picture event with media URL", zap.String("mediaURL", mediaURL))
// Extract a title from the caption or use the filename
title := caption
if title == "" {
title = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
}
event, err = eventManager.CreateAndSignPictureEvent(
pubkey,
content,
title, // Title parameter
content, // Description parameter
mediaURL,
contentType,
mediaHash,
@ -273,7 +306,9 @@ func main() {
nip94Uploader,
blossomUploader,
postContentFunc,
postContentEncodedFunc, // New encoded function
postContentEncodedFunc,
globalRelayService,
keyStore,
)
// Initialize API
@ -282,6 +317,7 @@ func main() {
botService,
authService,
posterScheduler,
globalRelayService,
)
// Start the scheduler

View File

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

View File

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

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"
"time"
"regexp"
"database/sql"
"github.com/gin-gonic/gin"
"github.com/nbd-wtf/go-nostr"
@ -29,6 +30,7 @@ type API struct {
botService *BotService
authService *auth.Service
scheduler *scheduler.Scheduler
globalRelayService *GlobalRelayService
}
// NewAPI creates a new API instance
@ -37,6 +39,7 @@ func NewAPI(
botService *BotService,
authService *auth.Service,
scheduler *scheduler.Scheduler,
globalRelayService *GlobalRelayService, // Changed from colon to comma
) *API {
router := gin.Default()
@ -46,6 +49,7 @@ func NewAPI(
botService: botService,
authService: authService,
scheduler: scheduler,
globalRelayService: globalRelayService, // Added this missing field
}
// Set up routes
@ -114,6 +118,16 @@ func (a *API) setupRoutes() {
statsGroup.GET("/:botId", a.getBotStats)
}
// Global relay management
globalRelayGroup := apiGroup.Group("/global-relays")
globalRelayGroup.Use(a.requireAuth)
{
globalRelayGroup.GET("", a.listGlobalRelays)
globalRelayGroup.POST("", a.addGlobalRelay)
globalRelayGroup.PUT("/:id", a.updateGlobalRelay)
globalRelayGroup.DELETE("/:id", a.deleteGlobalRelay)
}
// Serve the web UI
a.router.StaticFile("/", "./web/index.html")
a.router.StaticFile("/content.html", "./web/content.html")
@ -281,21 +295,39 @@ func (a *API) updateBot(c *gin.Context) {
return
}
var bot models.Bot
if err := c.ShouldBindJSON(&bot); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
var botUpdate models.Bot
if err := c.ShouldBindJSON(&botUpdate); err != nil {
a.logger.Error("Invalid bot data", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
return
}
// Log the received data for debugging
a.logger.Info("Received update data",
zap.Int64("bot_id", botID),
zap.String("name", botUpdate.Name),
zap.String("display_name", botUpdate.DisplayName),
zap.String("profile_picture", botUpdate.ProfilePicture),
zap.String("banner", botUpdate.Banner),
zap.Any("website", botUpdate.Website))
// Set the ID and owner
bot.ID = botID
bot.OwnerPubkey = pubkey
botUpdate.ID = botID
botUpdate.OwnerPubkey = pubkey
// Create SQL NullString for website if it's provided
if websiteStr, ok := c.GetPostForm("website"); ok && websiteStr != "" {
botUpdate.Website = sql.NullString{
String: websiteStr,
Valid: true,
}
}
// Update the bot
updatedBot, err := a.botService.UpdateBot(&bot)
updatedBot, err := a.botService.UpdateBot(&botUpdate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"})
a.logger.Error("Failed to update bot", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()})
return
}
@ -472,8 +504,8 @@ func (a *API) publishBotProfile(c *gin.Context) {
return
}
// Publish the profile
err = a.botService.PublishBotProfile(botID, pubkey)
// Publish the profile with NIP-19 encoding
event, err := a.botService.PublishBotProfileWithEncoding(botID, pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
a.logger.Error("Failed to publish bot profile", zap.Error(err))
@ -482,6 +514,7 @@ func (a *API) publishBotProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "Profile published successfully",
"event": event,
})
}
@ -743,6 +776,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
Filename string `json:"filename" binding:"required"`
Service string `json:"service" binding:"required"`
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
IsProfile bool `json:"isProfile"` // Optional: indicates this is a profile/banner image
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
@ -762,10 +796,18 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
// Create a new uploader instance that uses the bot's key.
var uploader scheduler.MediaUploader
if req.Service == "blossom" {
// Get the base Blossom server URL
serverURL := bot.MediaConfig.BlossomServerURL
if req.ServerURL != "" {
serverURL = req.ServerURL
// Get the base Blossom server URL using a fallback chain:
// 1. Request URL, 2. Bot's config URL, 3. Global config URL
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.BlossomServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config - you'll need to access this from somewhere
// Assuming we have a config accessor, something like:
serverURL = "https://cdn.sovbit.host" // Default from config.yaml as fallback
}
}
// Log the URL for debugging purposes
@ -792,10 +834,22 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
},
)
} else if req.Service == "nip94" {
serverURL := bot.MediaConfig.Nip94ServerURL
if req.ServerURL != "" {
serverURL = req.ServerURL
// Similar fallback chain for NIP-94
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.Nip94ServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config
serverURL = "https://files.sovbit.host" // Default from config.yaml as fallback
}
}
// Log the chosen server URL
a.logger.Info("Creating NIP-94 uploader with server URL",
zap.String("url", serverURL))
uploader = nip94.NewUploader(
serverURL,
"", // Download URL will be discovered
@ -814,9 +868,21 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
return
}
// Create appropriate caption and alt text for profile images
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
altText := caption
// For profile or banner images, set more appropriate caption/alt text
if req.IsProfile {
if strings.Contains(strings.ToLower(req.Filename), "profile") {
caption = fmt.Sprintf("%s's profile picture", bot.Name)
altText = caption
} else if strings.Contains(strings.ToLower(req.Filename), "banner") {
caption = fmt.Sprintf("%s's banner image", bot.Name)
altText = caption
}
}
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
if err != nil {
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
func (a *API) createManualPost(c *gin.Context) {
pubkey := c.GetString("pubkey")
botIDStr := c.Param("id")
botID, err := strconv.ParseInt(botIDStr, 10, 64)
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
return
@ -856,7 +921,7 @@ func (a *API) createManualPost(c *gin.Context) {
Kind int `json:"kind" binding:"required"`
Content string `json:"content" binding:"required"`
Title string `json:"title"`
Alt string `json:"alt"` // Added to support alt text for images
Alt string `json:"alt"`
Hashtags []string `json:"hashtags"`
}
@ -882,8 +947,6 @@ func (a *API) createManualPost(c *gin.Context) {
if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found
// Try to determine media type
mediaType = inferMediaTypeFromURL(mediaURL)
}
@ -894,7 +957,6 @@ func (a *API) createManualPost(c *gin.Context) {
switch req.Kind {
case 1:
// Standard text note
// Create tags
var tags []nostr.Tag
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
@ -910,7 +972,7 @@ func (a *API) createManualPost(c *gin.Context) {
mediaURL,
mediaType,
mediaHash,
req.Alt, // Use the alt text if provided
req.Alt,
req.Hashtags,
)
default:
@ -924,8 +986,17 @@ func (a *API) createManualPost(c *gin.Context) {
return
}
// Configure relay manager
for _, relay := range bot.Relays {
// Get combined relays (bot + global)
combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey)
if err != nil {
a.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Configure relay manager with combined relays
for _, relay := range combinedRelays {
if relay.Write {
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
a.logger.Warn("Failed to add relay",
@ -1000,3 +1071,101 @@ func extractHashtags(tags []nostr.Tag) []string {
}
return hashtags
}
// addGlobalRelay adds a global relay for the current user
func (a *API) addGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the owner pubkey
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.AddGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add global relay"})
a.logger.Error("Failed to add global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// updateGlobalRelay updates a global relay
func (a *API) updateGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the ID and owner pubkey
relay.ID = relayID
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.UpdateGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update global relay"})
a.logger.Error("Failed to update global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// deleteGlobalRelay deletes a global relay
func (a *API) deleteGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
if err := a.globalRelayService.DeleteGlobalRelay(relayID, pubkey); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete global relay"})
a.logger.Error("Failed to delete global relay", zap.Error(err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Global relay deleted"})
}
func (a *API) listGlobalRelays(c *gin.Context) {
pubkey := c.GetString("pubkey")
// Use your existing service method to get global relays
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get global relays"})
a.logger.Error("Failed to get global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}

View File

@ -33,11 +33,12 @@ func New(dbPath string) (*DB, error) {
db.MustExec("PRAGMA foreign_keys=ON;")
return &DB{db}, nil
}
// Initialize creates the database schema if it doesn't exist
func (db *DB) Initialize() error {
// Create bots table
// Create bots table with updated fields
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS bots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -50,6 +51,7 @@ func (db *DB) Initialize() error {
zap_address TEXT,
profile_picture TEXT,
banner TEXT,
website TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
owner_pubkey TEXT NOT NULL
)`)
@ -57,6 +59,24 @@ func (db *DB) Initialize() error {
return fmt.Errorf("failed to create bots table: %w", err)
}
// Check if website column exists, add it if it doesn't
var columnExists int
err = db.Get(&columnExists, `
SELECT COUNT(*) FROM pragma_table_info('bots') WHERE name = 'website'
`)
if err != nil {
return fmt.Errorf("failed to check if website column exists: %w", err)
}
if columnExists == 0 {
// Add the website column
_, err = db.Exec(`ALTER TABLE bots ADD COLUMN website TEXT`)
if err != nil {
return fmt.Errorf("failed to add website column: %w", err)
}
}
// Create post_config table
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS post_config (
@ -119,6 +139,19 @@ func (db *DB) Initialize() error {
return fmt.Errorf("failed to create posts table: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS global_relays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT 1,
write BOOLEAN NOT NULL DEFAULT 1,
owner_pubkey TEXT NOT NULL,
UNIQUE(url, owner_pubkey)
)`)
if err != nil {
return fmt.Errorf("failed to create global_relays table: %w", err)
}
return nil
}

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
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
// Ensure the URL ends with /upload for BUD-02 compliance
serverURL := u.serverURL
if !strings.HasSuffix(serverURL, "/upload") {
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
u.logger.Info("Adding /upload endpoint to URL for BUD-02 compliance",
zap.String("original_url", u.serverURL),
zap.String("adjusted_url", serverURL))
}
// Log information about the upload
u.logger.Info("Uploading file to Blossom server",
zap.String("filePath", filePath),
zap.String("serverURL", u.serverURL))
zap.String("serverURL", serverURL))
// Open the file
file, err := os.Open(filePath)
@ -181,7 +190,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
// Create the request with the file as the raw body
// This follows BUD-02 which states the endpoint must accept binary data in the body
req, err := http.NewRequest("PUT", u.serverURL, file)
req, err := http.NewRequest("PUT", serverURL, file)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
@ -206,7 +215,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
// Add authorization header if available
if u.getAuthHeader != nil {
authHeader, err := u.getAuthHeader(u.serverURL, "PUT")
authHeader, err := u.getAuthHeader(serverURL, "PUT")
if err != nil {
return "", "", fmt.Errorf("failed to create auth header: %w", err)
}
@ -242,13 +251,30 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
if resp.StatusCode != http.StatusOK &&
resp.StatusCode != http.StatusCreated &&
resp.StatusCode != http.StatusAccepted {
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr)
errorMsg := fmt.Sprintf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr)
// Add helpful diagnostics based on status code
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
errorMsg += " - This may indicate an authentication error. Check that your keys are correct and have permission to upload."
case http.StatusNotFound:
errorMsg += " - The upload endpoint was not found. Ensure the server URL is correct and includes the '/upload' path."
case http.StatusRequestEntityTooLarge:
errorMsg += " - The file is too large for this server. Try a smaller file or check server limits."
case http.StatusBadRequest:
errorMsg += " - The server rejected the request. Check that the file format is supported."
case http.StatusInternalServerError:
errorMsg += " - The server encountered an error. This may be temporary; try again later."
}
return "", "", fmt.Errorf(errorMsg)
}
// Parse response
var blossomResp BlossomResponse
if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil {
return "", "", fmt.Errorf("failed to parse response: %w", err)
// Try to provide useful error message even if JSON parsing fails
return "", "", fmt.Errorf("failed to parse server response: %w. Raw response: %s", err, bodyStr)
}
// Check for success
@ -256,6 +282,18 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message)
}
// Validate essential response fields
if blossomResp.URL == "" {
return "", "", fmt.Errorf("upload succeeded but server did not return a URL. Response: %s", bodyStr)
}
if blossomResp.SHA256 == "" {
// If hash is missing, use our calculated hash
u.logger.Warn("Server did not return a hash, using locally calculated hash",
zap.String("local_hash", fileHash))
blossomResp.SHA256 = fileHash
}
// Log the successful response
u.logger.Info("Upload successful",
zap.String("url", blossomResp.URL),
@ -269,8 +307,21 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
// DeleteFile deletes a file from the Blossom server
func (u *Uploader) DeleteFile(fileHash string) error {
// Ensure the base URL is properly formed for Blossom API
// For deletes, we need the base URL without the /upload part
baseURL := u.serverURL
if strings.HasSuffix(baseURL, "/upload") {
baseURL = strings.TrimSuffix(baseURL, "/upload")
u.logger.Info("Adjusting URL for deletion: removing /upload suffix",
zap.String("original_url", u.serverURL),
zap.String("adjusted_url", baseURL))
}
// Create the delete URL
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash)
u.logger.Info("Preparing to delete file from Blossom server",
zap.String("fileHash", fileHash),
zap.String("deleteURL", deleteURL))
// Create the request
req, err := http.NewRequest("DELETE", deleteURL, nil)
@ -299,12 +350,33 @@ func (u *Uploader) DeleteFile(fileHash string) error {
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
// Read response for better error reporting
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
}
@ -377,9 +449,22 @@ func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, er
// WithCustomURL creates a new uploader instance with the specified custom URL.
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
// Ensure the custom URL follows BUD-02 specification by having /upload endpoint
if !strings.HasSuffix(customURL, "/upload") {
customURL = strings.TrimSuffix(customURL, "/") + "/upload"
u.logger.Info("Adding /upload endpoint to custom URL for BUD-02 compliance",
zap.String("original_url", customURL),
zap.String("adjusted_url", customURL))
}
return &Uploader{
serverURL: customURL,
logger: u.logger,
getAuthHeader: u.getAuthHeader,
}
}
// GetServerURL returns the server URL
func (u *Uploader) GetServerURL() string {
return u.serverURL
}

View File

@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nbd-wtf/go-nostr"
@ -115,6 +116,17 @@ func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) {
// UploadFile uploads a file to a NIP-96 compatible server
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
// Use the server URL as provided - NIP-96 specifies POST to $api_url directly
serverURL := u.serverURL
// Remove any /upload suffix if it was added incorrectly
if strings.HasSuffix(serverURL, "/upload") {
serverURL = strings.TrimSuffix(serverURL, "/upload")
u.logger.Info("Removing /upload suffix from NIP-96 server URL",
zap.String("original_url", u.serverURL),
zap.String("adjusted_url", serverURL))
}
u.logger.Info("Using NIP-96 server URL for upload", zap.String("server_url", serverURL))
// Open the file
file, err := os.Open(filePath)
if err != nil {
@ -197,7 +209,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
}
// Create the request
req, err := http.NewRequest("POST", u.serverURL, &requestBody)
req, err := http.NewRequest("POST", serverURL, &requestBody)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
@ -216,7 +228,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
bodyHash := bodyHasher.Sum(nil)
// Use the body hash for authentication
authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash)
authHeader, err := u.getAuthHeader(serverURL, "POST", bodyHash)
if err != nil {
return "", "", fmt.Errorf("failed to create auth header: %w", err)
}
@ -383,8 +395,17 @@ func (u *Uploader) waitForProcessing(processingURL string) (string, string, erro
// DeleteFile deletes a file from the server
func (u *Uploader) DeleteFile(fileHash string) error {
// Ensure the base URL doesn't have the /upload suffix for deletion
baseURL := u.serverURL
if strings.HasSuffix(baseURL, "/upload") {
baseURL = strings.TrimSuffix(baseURL, "/upload")
u.logger.Info("Removing /upload endpoint for deletion",
zap.String("original_url", u.serverURL),
zap.String("adjusted_url", baseURL))
}
// Create the delete URL
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash)
// Create the request
req, err := http.NewRequest("DELETE", deleteURL, nil)
@ -475,6 +496,14 @@ func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (
// WithCustomURL creates a new uploader instance with the specified custom URL
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
// NIP-96 specifies POST to $api_url directly without /upload
if strings.HasSuffix(customURL, "/upload") {
customURL = strings.TrimSuffix(customURL, "/upload")
u.logger.Info("Removing /upload suffix from custom NIP-96 URL",
zap.String("original_url", customURL),
zap.String("adjusted_url", customURL))
}
// Create a new uploader with the same configuration but a different URL
return &Uploader{
serverURL: customURL,
@ -484,3 +513,8 @@ func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
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
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
@ -17,6 +19,8 @@ type Bot struct {
ZapAddress string `db:"zap_address" json:"zap_address"`
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
Banner string `db:"banner" json:"banner"`
Website sql.NullString `db:"website" json:"website,omitempty"` // Changed to sql.NullString to handle NULL values
// Custom JSON marshaling is handled in MarshalJSON/UnmarshalJSON methods
CreatedAt time.Time `db:"created_at" json:"created_at"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
@ -53,6 +57,7 @@ type Relay struct {
URL string `db:"url" json:"url"`
Read bool `db:"read" json:"read"`
Write bool `db:"write" json:"write"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey,omitempty"` // Add this field
}
// Post represents a post made by the bot
@ -66,3 +71,67 @@ type Post struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
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
func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) {
// Create the metadata structure
// Create a comprehensive metadata structure according to NIP-01
metadata := map[string]interface{}{
"name": bot.Name,
"display_name": bot.DisplayName,
"about": bot.Bio,
}
// Add optional fields if they exist
// Add optional fields only if they exist and aren't empty
if bot.Nip05 != "" {
metadata["nip05"] = bot.Nip05
}
@ -47,6 +47,15 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
metadata["banner"] = bot.Banner
}
if bot.ZapAddress != "" {
metadata["lud16"] = bot.ZapAddress // Lightning Address (NIP-57)
}
// Add website field if present
if bot.Website.Valid && bot.Website.String != "" {
metadata["website"] = bot.Website.String
}
// Convert metadata to JSON
content, err := json.Marshal(metadata)
if err != nil {
@ -55,7 +64,7 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
// Create the event
ev := nostr.Event{
Kind: 0,
Kind: 0, // User metadata event
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: []nostr.Tag{},
Content: string(content),

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
if isImage {
// Use kind 1 (text note) for images
// Log the media URL for debugging
p.logger.Info("Creating image post with media URL",
zap.String("mediaURL", mediaURL),
zap.String("mediaHash", mediaHash),
zap.String("contentType", contentType))
// Create hashtag string for post content
var hashtagStr string
if len(hashtags) > 0 {
@ -111,9 +116,11 @@ func (p *Poster) PostContent(
content := caption + hashtagStr
event, err = p.eventMgr.CreateAndSignMediaEvent(
// Use kind 20 (picture post) for better media compatibility
event, err = p.eventMgr.CreateAndSignPictureEvent(
pubkey,
content,
caption, // Title
content, // Description
mediaURL,
contentType,
mediaHash,

View File

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

View File

@ -14,9 +14,15 @@ import (
"git.sovbit.dev/Enki/nostr-poster/internal/db"
"git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
"go.uber.org/zap"
)
// RelayService defines the interface for relay service operations
type RelayService interface {
GetAllRelaysForPosting(botID int64, ownerPubkey string) ([]*models.Relay, error)
}
// MediaUploader defines the interface for uploading media
type MediaUploader interface {
UploadFile(filePath string, caption string, altText string) (string, string, error)
@ -61,9 +67,11 @@ type Scheduler struct {
nip94Uploader MediaUploader
blossomUploader MediaUploader
postContent ContentPoster
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
postContentEncoded ContentPosterWithEncoding
botJobs map[int64]cron.EntryID
mu sync.RWMutex
globalRelayService RelayService
keyStore *crypto.KeyStore
}
// NewScheduler creates a new content scheduler
@ -75,7 +83,9 @@ func NewScheduler(
nip94Uploader MediaUploader,
blossomUploader MediaUploader,
postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
postContentEncoded ContentPosterWithEncoding,
globalRelayService RelayService,
keyStore *crypto.KeyStore,
) *Scheduler {
if logger == nil {
// Create a default logger
@ -91,15 +101,17 @@ func NewScheduler(
return &Scheduler{
db: db,
cron: cronScheduler,
cron: cronScheduler, // Use the local variable
logger: logger,
contentDir: contentDir,
archiveDir: archiveDir,
nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader,
postContent: postContent,
postContentEncoded: postContentEncoded, // Initialize the new field
postContentEncoded: postContentEncoded,
globalRelayService: globalRelayService,
botJobs: make(map[int64]cron.EntryID),
keyStore: keyStore,
}
}
@ -107,7 +119,8 @@ func NewScheduler(
func (s *Scheduler) Start() error {
// Load all bots with enabled post configs
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
JOIN post_config pc ON b.id = pc.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 mediaConfig models.MediaConfig
// Map the results to our structs
err := rows.Scan(
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName,
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner,
&bot.CreatedAt, &bot.OwnerPubkey,
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes,
&postConfig.PostTemplate, &postConfig.Enabled,
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
&bot.ID, &bot.Pubkey, &bot.Name, &bot.DisplayName,
&postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags,
&mediaConfig.PrimaryService, &mediaConfig.FallbackService,
&mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
)
if err != nil {
s.logger.Error("Failed to scan bot row", zap.Error(err))
@ -230,7 +239,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
return
}
// Select the appropriate uploader
// Get the appropriate uploader for this bot
var uploader MediaUploader
if bot.MediaConfig.PrimaryService == "blossom" {
uploader = s.blossomUploader
@ -276,7 +285,32 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
filename := filepath.Base(contentPath)
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
// Post the content - use encoded version if available, otherwise use the original
// Get the owner pubkey of the bot
var ownerPubkey string
err = s.db.Get(&ownerPubkey, "SELECT owner_pubkey FROM bots WHERE id = ?", bot.ID)
if err != nil {
s.logger.Error("Failed to get bot owner pubkey",
zap.Int64("bot_id", bot.ID),
zap.Error(err))
ownerPubkey = "" // Default to empty if not found
}
// Set up relays if owner found
if ownerPubkey != "" && s.globalRelayService != nil {
// Get combined relays
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(bot.ID, ownerPubkey)
if err == nil && len(combinedRelays) > 0 {
// Use combined relays if available
for _, relay := range combinedRelays {
// Add to both the bot's relays list and the relay manager
if !containsRelay(bot.Relays, relay.URL) {
bot.Relays = append(bot.Relays, relay)
}
}
}
}
// Rest of the function remains the same
var postErr error
if s.postContentEncoded != nil {
@ -449,3 +483,12 @@ func (s *Scheduler) RunNow(botID int64) error {
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)
const botSettingsModalEl = document.getElementById('botSettingsModal');
const profilePictureInput = document.getElementById('botProfilePicture');
const uploadProfilePictureBtn = document.getElementById('uploadProfilePicture');
const profilePicturePreview = document.getElementById('profilePicturePreview');
const profilePreviewImage = document.getElementById('profilePreviewImage');
const removeProfilePictureBtn = document.getElementById('removeProfilePicture');
const profilePictureUrl = document.getElementById('profilePictureUrl');
// Banner image upload handlers for Create Bot Modal
const bannerInput = document.getElementById('botBanner');
const uploadBannerBtn = document.getElementById('uploadBanner');
const bannerPreview = document.getElementById('bannerPreview');
const bannerPreviewImage = document.getElementById('bannerPreviewImage');
const removeBannerBtn = document.getElementById('removeBanner');
const bannerUrl = document.getElementById('bannerUrl');
// Profile image upload handlers for Bot Settings Modal
const settingsProfilePictureInput = document.getElementById('botSettingsProfilePicture');
const uploadSettingsProfilePictureBtn = document.getElementById('uploadSettingsProfilePicture');
const settingsProfilePicturePreview = document.getElementById('settingsProfilePicturePreview');
const settingsProfilePictureContainer = document.getElementById('settingsProfilePictureContainer');
const settingsProfilePictureEmpty = document.getElementById('settingsProfilePictureEmpty');
const settingsProfilePreviewImage = document.getElementById('settingsProfilePreviewImage');
const removeSettingsProfilePictureBtn = document.getElementById('removeSettingsProfilePicture');
const settingsProfilePictureUrl = document.getElementById('settingsProfilePictureUrl');
// Banner image upload handlers for Bot Settings Modal
const settingsBannerInput = document.getElementById('botSettingsBanner');
const uploadSettingsBannerBtn = document.getElementById('uploadSettingsBanner');
const settingsBannerPreview = document.getElementById('settingsBannerPreview');
const settingsBannerContainer = document.getElementById('settingsBannerContainer');
const settingsBannerEmpty = document.getElementById('settingsBannerEmpty');
const settingsBannerPreviewImage = document.getElementById('settingsBannerPreviewImage');
const removeSettingsBannerBtn = document.getElementById('removeSettingsBanner');
const settingsBannerUrl = document.getElementById('settingsBannerUrl');
const globalRelaysList = document.getElementById('global-relays-list');
const addGlobalRelayBtn = document.getElementById('add-global-relay-btn');
const saveGlobalRelayBtn = document.getElementById('save-global-relay-btn');
const globalRelayModalEl = document.getElementById('globalRelayModal');
/* ----------------------------------------------------
* Bootstrap Modal instance
* -------------------------------------------------- */
@ -37,11 +77,17 @@ document.addEventListener('DOMContentLoaded', () => {
botSettingsModal = new bootstrap.Modal(botSettingsModalEl);
}
let profileImageURL = '';
let bannerImageURL = '';
let settingsProfileImageURL = '';
let settingsBannerImageURL = '';
let globalRelays = [];
let globalRelayModal;
/* ----------------------------------------------------
* Global State
* -------------------------------------------------- */
let currentUser = null;
const API_ENDPOINT = ''; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765'
const API_ENDPOINT = 'http://localhost:8765'; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765'
/* ----------------------------------------------------
* On page load, check if already logged in
@ -54,6 +100,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (loginButton) loginButton.addEventListener('click', login);
if (logoutButton) logoutButton.addEventListener('click', logout);
if (addGlobalRelayBtn) addGlobalRelayBtn.addEventListener('click', showAddGlobalRelayModal);
if (saveGlobalRelayBtn) saveGlobalRelayBtn.addEventListener('click', addGlobalRelay);
if (createBotBtn) createBotBtn.addEventListener('click', showCreateBotModal);
if (saveBotBtn) saveBotBtn.addEventListener('click', createBot);
@ -136,9 +185,56 @@ 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
* -------------------------------------------------- */
// In main.js, look at the checkAuth function
async function checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) {
@ -153,20 +249,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (response.ok) {
const data = await response.json();
// data.pubkey is currently hex
// Convert to npub using nip19 from nostr-tools
const { nip19 } = window.nostrTools;
const userNpub = nip19.npubEncode(data.pubkey);
// Store npub as currentUser
currentUser = userNpub;
showMainContent();
// Always load bots if on the main page
fetchBots();
fetchGlobalRelays(); // Make sure this function is called
} else {
// Token invalid
localStorage.removeItem('authToken');
showAuthSection();
}
@ -224,6 +314,37 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
async function checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) {
showAuthSection();
return;
}
try {
const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, {
headers: { 'Authorization': token }
});
if (response.ok) {
const data = await response.json();
const { nip19 } = window.nostrTools;
const userNpub = nip19.npubEncode(data.pubkey);
currentUser = userNpub;
showMainContent();
fetchBots();
fetchGlobalRelays(); // Add this line to load global relays
} else {
localStorage.removeItem('authToken');
showAuthSection();
}
} catch (error) {
console.error('Auth check failed:', error);
showAuthSection();
}
}
function logout() {
localStorage.removeItem('authToken');
currentUser = null;
@ -477,6 +598,20 @@ document.addEventListener('DOMContentLoaded', () => {
}
window.publishBotProfile = async function(botId) {
// Find the publish button
const publishBtn = document.getElementById('publishProfileBtn');
// If not found, try to find it by class (might be in bot card)
const btnSelector = publishBtn || document.querySelector(`button[onclick*="publishBotProfile(${botId})"]`);
// Create a loading state for the button if found
let originalBtnText = '';
if (btnSelector) {
originalBtnText = btnSelector.innerHTML;
btnSelector.disabled = true;
btnSelector.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Publishing...';
}
try {
const token = localStorage.getItem('authToken');
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
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
const modalId = 'eventInfoModal';
const modalId = 'profilePublishedModal';
// Remove any existing modal with the same ID
const existingModal = document.getElementById(modalId);
@ -504,23 +660,35 @@ document.addEventListener('DOMContentLoaded', () => {
existingModal.remove();
}
const eventModal = document.createElement('div');
eventModal.className = 'modal fade';
eventModal.id = modalId;
eventModal.setAttribute('tabindex', '-1');
eventModal.innerHTML = `
<div class="modal-dialog">
// Create the modal
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = modalId;
modal.setAttribute('tabindex', '-1');
modal.setAttribute('aria-labelledby', `${modalId}Label`);
modal.setAttribute('aria-hidden', 'true');
// Create the modal content
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<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>
</div>
<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>
<div class="input-group">
<input type="text" class="form-control" value="${data.event.note || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.note || ''}">
<div class="input-group mb-2">
<input type="text" class="form-control font-monospace small" value="${eventData.note || ''}" readonly>
<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">
<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"/>
@ -528,11 +696,12 @@ document.addEventListener('DOMContentLoaded', () => {
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">Event with Relays (NIP-19):</label>
<div class="input-group">
<input type="text" class="form-control" value="${data.event.nevent || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.nevent || ''}">
<div class="mt-3">
<label class="form-label">Event with Relay Info (NIP-19):</label>
<div class="input-group mb-2">
<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">
<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"/>
@ -540,6 +709,12 @@ document.addEventListener('DOMContentLoaded', () => {
</button>
</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 class="modal-footer">
<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
document.body.appendChild(eventModal);
document.body.appendChild(modal);
// Show the modal using Bootstrap
const bsModal = new bootstrap.Modal(document.getElementById(modalId));
bsModal.show();
// Show the modal
const modalInstance = new bootstrap.Modal(document.getElementById(modalId));
modalInstance.show();
// Add event listeners to copy buttons
document.querySelectorAll('.copy-btn').forEach(button => {
button.addEventListener('click', function() {
const text = this.getAttribute('data-value');
navigator.clipboard.writeText(text).then(() => {
// Visual feedback that copy succeeded
const originalHTML = this.innerHTML;
this.innerHTML = `
<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
* -------------------------------------------------- */
@ -573,6 +760,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (createBotForm) createBotForm.reset();
// Default: keyOption = "generate" → hide nsecKeyInput
const keyOption = document.getElementById('keyOption');
const nsecKeyInput = document.getElementById('nsecKeyInput');
if (keyOption) {
keyOption.value = 'generate';
}
@ -580,9 +769,308 @@ document.addEventListener('DOMContentLoaded', () => {
nsecKeyInput.style.display = 'none';
}
// Show modal
if (createBotModal) {
// Show modal - ensure modal is initialized
if (typeof bootstrap !== 'undefined' && createBotModalEl) {
if (!createBotModal) {
createBotModal = new bootstrap.Modal(createBotModalEl);
}
createBotModal.show();
} else {
console.error('Bootstrap or modal element not found');
}
}
async function fetchGlobalRelays() {
if (!globalRelaysList) return;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_ENDPOINT}/api/global-relays`, {
headers: { 'Authorization': token }
});
if (response.ok) {
const relays = await response.json();
renderGlobalRelays(relays);
} else {
console.error('Failed to fetch global relays');
globalRelaysList.innerHTML = '<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 bio = document.getElementById('botBio').value.trim();
const nip05 = document.getElementById('botNip05').value.trim();
const website = document.getElementById('botWebsite')?.value.trim() || ''; // New field
const keyChoice = keyOption ? keyOption.value : 'generate'; // fallback
// Validate form
@ -606,12 +1095,15 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Build request data
// Build request data with added profile/banner fields
const requestData = {
name: name,
display_name: displayName,
bio: bio,
nip05: nip05
nip05: nip05,
website: website, // Add website field
profile_picture: profileImageURL, // Add profile image URL
banner: bannerImageURL // Add banner image URL
};
// If user selected "import", grab the NSEC key
@ -653,11 +1145,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (response.ok) {
console.log('Bot created successfully!');
// Hide modal
if (typeof bootstrap !== 'undefined') {
const modal = bootstrap.Modal.getInstance(createBotModalEl);
if (modal) modal.hide();
}
// Reset image URLs
profileImageURL = '';
bannerImageURL = '';
// Refresh bot list
fetchBots();
} else {
@ -731,9 +1229,11 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
headers: { 'Authorization': token }
});
if (!response.ok) {
throw new Error('Failed to load bot data');
}
const bot = await response.json();
// Fill form fields
@ -743,30 +1243,81 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('botSettingsNip05').value = bot.nip05 || '';
document.getElementById('botSettingsZap').value = bot.zap_address || '';
// Add website field if it exists
const websiteField = document.getElementById('botSettingsWebsite');
if (websiteField) {
websiteField.value = bot.website || '';
}
// Fill image fields
if (settingsProfilePictureContainer && settingsProfilePictureEmpty &&
settingsProfilePreviewImage && settingsProfilePictureUrl) {
if (bot.profile_picture) {
settingsProfilePreviewImage.src = bot.profile_picture;
settingsProfilePictureUrl.textContent = 'Current profile picture';
settingsProfilePictureContainer.classList.remove('d-none');
settingsProfilePictureEmpty.classList.add('d-none');
settingsProfileImageURL = bot.profile_picture;
} else {
settingsProfilePictureContainer.classList.add('d-none');
settingsProfilePictureEmpty.classList.remove('d-none');
settingsProfileImageURL = '';
}
}
// Banner
if (settingsBannerContainer && settingsBannerEmpty &&
settingsBannerPreviewImage && settingsBannerUrl) {
if (bot.banner) {
settingsBannerPreviewImage.src = bot.banner;
settingsBannerUrl.textContent = 'Current banner';
settingsBannerContainer.classList.remove('d-none');
settingsBannerEmpty.classList.add('d-none');
settingsBannerImageURL = bot.banner;
} else {
settingsBannerContainer.classList.add('d-none');
settingsBannerEmpty.classList.remove('d-none');
settingsBannerImageURL = '';
}
}
// If post_config is present
if (bot.post_config) {
document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60;
// hashtags is stored as a JSON string
const hashtagsJson = bot.post_config.hashtags || '[]';
const tagsArr = JSON.parse(hashtagsJson);
document.getElementById('botSettingsHashtags').value = tagsArr.join(', ');
// Parse hashtags
try {
const hashtags = JSON.parse(bot.post_config.hashtags || '[]');
document.getElementById('botSettingsHashtags').value = hashtags.join(', ');
} catch (e) {
console.error('Failed to parse hashtags', e);
document.getElementById('botSettingsHashtags').value = '';
}
}
// Show the modal
const modalEl = document.getElementById('botSettingsModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
if (typeof bootstrap !== 'undefined' && botSettingsModalEl) {
if (!botSettingsModal) {
botSettingsModal = new bootstrap.Modal(botSettingsModalEl);
}
botSettingsModal.show();
} else {
console.error('Bootstrap or modal element not found');
alert('Could not open settings modal. Please check the console for errors.');
}
// Store bot ID so we know which bot to save
// Store bot ID for saving
document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId);
} catch (err) {
console.error('Error loading bot data:', err);
console.error('Error loading bot settings:', err);
alert('Error loading bot: ' + err.message);
}
};
// saveBotSettings function to include website and images
window.saveBotSettings = async function () {
const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id');
if (!botId) {
@ -780,26 +1331,74 @@ document.addEventListener('DOMContentLoaded', () => {
const nip05 = document.getElementById('botSettingsNip05').value.trim();
const zap = document.getElementById('botSettingsZap').value.trim();
// Get website field if it exists
const websiteField = document.getElementById('botSettingsWebsite');
const website = websiteField ? websiteField.value.trim() : '';
// Validate required fields
if (!name) {
alert('Bot name is required');
return;
}
// Log the data we're about to send
console.log('Saving bot settings:', {
botId,
name,
display_name: displayName,
bio,
nip05,
zap_address: zap,
website,
profile_picture: settingsProfileImageURL,
banner: settingsBannerImageURL
});
// 1) Update the basic fields (PUT /api/bots/:id)
try {
const token = localStorage.getItem('authToken');
// Prepare the request payload
const botData = {
name,
display_name: displayName,
bio,
nip05,
zap_address: zap
};
// Only add these fields if they have values
if (website) {
botData.website = website;
}
if (settingsProfileImageURL) {
botData.profile_picture = settingsProfileImageURL;
}
if (settingsBannerImageURL) {
botData.banner = settingsBannerImageURL;
}
const updateResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: JSON.stringify({
name,
display_name: displayName,
bio,
nip05,
zap_address: zap
})
body: JSON.stringify(botData)
});
if (!updateResp.ok) {
const errData = await updateResp.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to update bot info');
// Attempt to get detailed error message
let errorMessage = 'Failed to update bot info';
try {
const errorData = await updateResp.json();
errorMessage = errorData.error || errorMessage;
} catch (e) {
console.error('Failed to parse error response', e);
}
throw new Error(errorMessage);
}
} catch (err) {
console.error('Failed to update bot info:', err);
@ -851,6 +1450,76 @@ document.addEventListener('DOMContentLoaded', () => {
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
* -------------------------------------------------- */

View File

@ -138,6 +138,106 @@
<!-- Bot cards will be rendered here -->
</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>
@ -169,6 +269,61 @@
<input type="text" class="form-control" id="botDisplayName"
placeholder="e.g., My Art Posting Bot">
</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">
<label for="botBio" class="form-label">Bio</label>
<textarea class="form-control" id="botBio" rows="3"
@ -180,6 +335,12 @@
placeholder="e.g., bot@yourdomain.com">
<div class="form-text">Optional. Used for verification.</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">
<div class="mb-3">
@ -243,6 +404,7 @@
<label for="botSettingsDisplayName" class="form-label">Display Name</label>
<input type="text" class="form-control" id="botSettingsDisplayName">
</div>
<div class="mb-3">
<label for="botSettingsBio" class="form-label">Bio</label>
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
@ -256,8 +418,87 @@
<input type="text" class="form-control" id="botSettingsZap">
</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 -->
<div class="mb-3">
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>