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

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,25 +23,29 @@ type BotService struct {
eventMgr *events.EventManager
relayMgr *relay.Manager
logger *zap.Logger
globalRelayService *GlobalRelayService
}
// NewBotService creates a new BotService
func NewBotService(
db *db.DB,
keyStore *crypto.KeyStore,
eventMgr *events.EventManager,
relayMgr *relay.Manager,
logger *zap.Logger,
db *db.DB,
keyStore *crypto.KeyStore,
eventMgr *events.EventManager,
relayMgr *relay.Manager,
globalRelayService *GlobalRelayService,
logger *zap.Logger,
) *BotService {
return &BotService{
db: db,
keyStore: keyStore,
eventMgr: eventMgr,
relayMgr: relayMgr,
logger: logger,
}
return &BotService{
db: db,
keyStore: keyStore,
eventMgr: eventMgr,
relayMgr: relayMgr,
globalRelayService: globalRelayService,
logger: logger,
}
}
// GetPrivateKey returns the private key for the given pubkey from the keystore.
func (s *BotService) GetPrivateKey(pubkey string) (string, error) {
return s.keyStore.GetPrivateKey(pubkey)
@ -248,8 +252,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
Read bool
Write bool
}{
{"wss://relay.damus.io", true, true},
{"wss://nostr.mutinywallet.com", true, true},
{"wss://freelay.sovbit.host", true, true},
{"wss://relay.nostr.band", true, true},
}
@ -278,8 +281,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
bot.PostConfig = postConfig
bot.MediaConfig = mediaConfig
bot.Relays = []*models.Relay{
{BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true},
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
{BotID: botID, URL: "wss://freelay.sovbit.host", Read: true, Write: true},
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
}
@ -299,6 +301,18 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Log what we're updating
s.logger.Info("Updating bot",
zap.Int64("id", bot.ID),
zap.String("name", bot.Name),
zap.String("display_name", bot.DisplayName),
zap.String("bio", bot.Bio),
zap.String("nip05", bot.Nip05),
zap.String("zap_address", bot.ZapAddress),
zap.String("profile_picture", bot.ProfilePicture),
zap.String("banner", bot.Banner),
zap.Any("website", bot.Website))
// We don't update the pubkey or encrypted_privkey
query := `
UPDATE bots SET
@ -308,14 +322,24 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
nip05 = ?,
zap_address = ?,
profile_picture = ?,
banner = ?
banner = ?,
website = ?
WHERE id = ? AND owner_pubkey = ?
`
// Handle nullable website field
var websiteVal interface{}
if bot.Website.Valid {
websiteVal = bot.Website.String
} else {
websiteVal = nil
}
_, err = s.db.Exec(
query,
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
websiteVal,
bot.ID, bot.OwnerPubkey,
)
if err != nil {
@ -500,75 +524,93 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*
// PublishBotProfile publishes a bot's profile to Nostr
func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
// Get the bot
bot, err := s.GetBotByID(botID, ownerPubkey)
if err != nil {
return fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Get the bot
bot, err := s.GetBotByID(botID, ownerPubkey)
if err != nil {
return fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Create and sign the metadata event
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
if err != nil {
return fmt.Errorf("failed to create metadata event: %w", err)
}
// Create and sign the metadata event
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
if err != nil {
return fmt.Errorf("failed to create metadata event: %w", err)
}
// Set up relay connections
for _, relay := range bot.Relays {
if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
// Get combined relays (bot + global)
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey)
if err != nil {
s.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Publish the event
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Set up relay connections
for _, relay := range combinedRelays {
if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
published, err := s.relayMgr.PublishEvent(ctx, event)
if err != nil {
return fmt.Errorf("failed to publish profile: %w", err)
}
// Publish the event
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.logger.Info("Published profile to relays",
zap.Int64("botID", botID),
zap.Strings("relays", published))
published, err := s.relayMgr.PublishEvent(ctx, event)
if err != nil {
return fmt.Errorf("failed to publish profile: %w", err)
}
return nil
s.logger.Info("Published profile to relays",
zap.Int64("botID", botID),
zap.Strings("relays", published))
return nil
}
// PublishBotProfileWithEncoding publishes a bot's profile and returns the encoded event
func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) {
// Get the bot
bot, err := s.GetBotByID(botID, ownerPubkey)
if err != nil {
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Get the bot
bot, err := s.GetBotByID(botID, ownerPubkey)
if err != nil {
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Create and sign the metadata event
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
if err != nil {
return nil, fmt.Errorf("failed to create metadata event: %w", err)
}
// Create and sign the metadata event
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
if err != nil {
return nil, fmt.Errorf("failed to create metadata event: %w", err)
}
// Set up relay connections
for _, relay := range bot.Relays {
if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
// Get combined relays (bot + global)
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey)
if err != nil {
s.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Publish the event with encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Set up relay connections
for _, relay := range combinedRelays {
if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
return s.relayMgr.PublishEventWithEncoding(ctx, event)
// Publish the event with encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return s.relayMgr.PublishEventWithEncoding(ctx, event)
}
// Helper function to load related data for a bot

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,29 +30,32 @@ type API struct {
botService *BotService
authService *auth.Service
scheduler *scheduler.Scheduler
globalRelayService *GlobalRelayService
}
// NewAPI creates a new API instance
func NewAPI(
logger *zap.Logger,
botService *BotService,
authService *auth.Service,
scheduler *scheduler.Scheduler,
logger *zap.Logger,
botService *BotService,
authService *auth.Service,
scheduler *scheduler.Scheduler,
globalRelayService *GlobalRelayService, // Changed from colon to comma
) *API {
router := gin.Default()
api := &API{
router: router,
logger: logger,
botService: botService,
authService: authService,
scheduler: scheduler,
}
// Set up routes
api.setupRoutes()
return api
router := gin.Default()
api := &API{
router: router,
logger: logger,
botService: botService,
authService: authService,
scheduler: scheduler,
globalRelayService: globalRelayService, // Added this missing field
}
// Set up routes
api.setupRoutes()
return api
}
// SetupRoutes configures the API routes
@ -114,6 +118,16 @@ func (a *API) setupRoutes() {
statsGroup.GET("/:botId", a.getBotStats)
}
// Global relay management
globalRelayGroup := apiGroup.Group("/global-relays")
globalRelayGroup.Use(a.requireAuth)
{
globalRelayGroup.GET("", a.listGlobalRelays)
globalRelayGroup.POST("", a.addGlobalRelay)
globalRelayGroup.PUT("/:id", a.updateGlobalRelay)
globalRelayGroup.DELETE("/:id", a.deleteGlobalRelay)
}
// Serve the web UI
a.router.StaticFile("/", "./web/index.html")
a.router.StaticFile("/content.html", "./web/content.html")
@ -281,21 +295,39 @@ func (a *API) updateBot(c *gin.Context) {
return
}
var bot models.Bot
if err := c.ShouldBindJSON(&bot); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
var botUpdate models.Bot
if err := c.ShouldBindJSON(&botUpdate); err != nil {
a.logger.Error("Invalid bot data", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
return
}
// Log the received data for debugging
a.logger.Info("Received update data",
zap.Int64("bot_id", botID),
zap.String("name", botUpdate.Name),
zap.String("display_name", botUpdate.DisplayName),
zap.String("profile_picture", botUpdate.ProfilePicture),
zap.String("banner", botUpdate.Banner),
zap.Any("website", botUpdate.Website))
// Set the ID and owner
bot.ID = botID
bot.OwnerPubkey = pubkey
botUpdate.ID = botID
botUpdate.OwnerPubkey = pubkey
// Create SQL NullString for website if it's provided
if websiteStr, ok := c.GetPostForm("website"); ok && websiteStr != "" {
botUpdate.Website = sql.NullString{
String: websiteStr,
Valid: true,
}
}
// Update the bot
updatedBot, err := a.botService.UpdateBot(&bot)
updatedBot, err := a.botService.UpdateBot(&botUpdate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"})
a.logger.Error("Failed to update bot", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()})
return
}
@ -472,8 +504,8 @@ func (a *API) publishBotProfile(c *gin.Context) {
return
}
// Publish the profile
err = a.botService.PublishBotProfile(botID, pubkey)
// Publish the profile with NIP-19 encoding
event, err := a.botService.PublishBotProfileWithEncoding(botID, pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
a.logger.Error("Failed to publish bot profile", zap.Error(err))
@ -482,6 +514,7 @@ func (a *API) publishBotProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "Profile published successfully",
"event": event,
})
}
@ -743,6 +776,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
Filename string `json:"filename" binding:"required"`
Service string `json:"service" binding:"required"`
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
IsProfile bool `json:"isProfile"` // Optional: indicates this is a profile/banner image
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
@ -762,40 +796,60 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
// Create a new uploader instance that uses the bot's key.
var uploader scheduler.MediaUploader
if req.Service == "blossom" {
// Get the base Blossom server URL
serverURL := bot.MediaConfig.BlossomServerURL
if req.ServerURL != "" {
serverURL = req.ServerURL
}
// Log the URL for debugging purposes
a.logger.Info("Creating Blossom uploader with server URL",
zap.String("original_url", serverURL))
// According to BUD-02 specification, the upload endpoint should be /upload
// Make sure the URL ends with /upload
if !strings.HasSuffix(serverURL, "/upload") {
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
a.logger.Info("Adding /upload endpoint to URL",
zap.String("complete_url", serverURL))
}
uploader = blossom.NewUploader(
serverURL,
a.logger,
func(url, method string) (string, error) {
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
if err != nil {
return "", err
}
return blossom.CreateBlossomAuthHeader(url, method, privkey)
},
)
} else if req.Service == "nip94" {
serverURL := bot.MediaConfig.Nip94ServerURL
if req.ServerURL != "" {
serverURL = req.ServerURL
// Get the base Blossom server URL using a fallback chain:
// 1. Request URL, 2. Bot's config URL, 3. Global config URL
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.BlossomServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config - you'll need to access this from somewhere
// Assuming we have a config accessor, something like:
serverURL = "https://cdn.sovbit.host" // Default from config.yaml as fallback
}
}
// Log the URL for debugging purposes
a.logger.Info("Creating Blossom uploader with server URL",
zap.String("original_url", serverURL))
// According to BUD-02 specification, the upload endpoint should be /upload
// Make sure the URL ends with /upload
if !strings.HasSuffix(serverURL, "/upload") {
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
a.logger.Info("Adding /upload endpoint to URL",
zap.String("complete_url", serverURL))
}
uploader = blossom.NewUploader(
serverURL,
a.logger,
func(url, method string) (string, error) {
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
if err != nil {
return "", err
}
return blossom.CreateBlossomAuthHeader(url, method, privkey)
},
)
} else if req.Service == "nip94" {
// Similar fallback chain for NIP-94
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.Nip94ServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config
serverURL = "https://files.sovbit.host" // Default from config.yaml as fallback
}
}
// Log the chosen server URL
a.logger.Info("Creating NIP-94 uploader with server URL",
zap.String("url", serverURL))
uploader = nip94.NewUploader(
serverURL,
"", // Download URL will be discovered
@ -814,8 +868,20 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
return
}
// Create appropriate caption and alt text for profile images
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
altText := caption
// For profile or banner images, set more appropriate caption/alt text
if req.IsProfile {
if strings.Contains(strings.ToLower(req.Filename), "profile") {
caption = fmt.Sprintf("%s's profile picture", bot.Name)
altText = caption
} else if strings.Contains(strings.ToLower(req.Filename), "banner") {
caption = fmt.Sprintf("%s's banner image", bot.Name)
altText = caption
}
}
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
if err != nil {
@ -836,122 +902,127 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
// Updated createManualPost function in routes.go
func (a *API) createManualPost(c *gin.Context) {
pubkey := c.GetString("pubkey")
botIDStr := c.Param("id")
botID, err := strconv.ParseInt(botIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
return
}
pubkey := c.GetString("pubkey")
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
return
}
// Ensure the bot belongs to the user
bot, err := a.botService.GetBotByID(botID, pubkey)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
return
}
// Ensure the bot belongs to the user
bot, err := a.botService.GetBotByID(botID, pubkey)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
return
}
// Parse request body
var req struct {
Kind int `json:"kind" binding:"required"`
Content string `json:"content" binding:"required"`
Title string `json:"title"`
Alt string `json:"alt"` // Added to support alt text for images
Hashtags []string `json:"hashtags"`
}
// Parse request body
var req struct {
Kind int `json:"kind" binding:"required"`
Content string `json:"content" binding:"required"`
Title string `json:"title"`
Alt string `json:"alt"`
Hashtags []string `json:"hashtags"`
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// For kind 20 (picture post), title is required
if req.Kind == 20 && req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
return
}
// For kind 20 (picture post), title is required
if req.Kind == 20 && req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
return
}
// Process content to extract media URLs
var mediaURL string
var mediaType string
var mediaHash string
// Check if content contains URLs
re := regexp.MustCompile(`https?://[^\s]+`)
matches := re.FindAllString(req.Content, -1)
if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found
// Try to determine media type
mediaType = inferMediaTypeFromURL(mediaURL)
}
// Process content to extract media URLs
var mediaURL string
var mediaType string
var mediaHash string
// Check if content contains URLs
re := regexp.MustCompile(`https?://[^\s]+`)
matches := re.FindAllString(req.Content, -1)
if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found
mediaType = inferMediaTypeFromURL(mediaURL)
}
// Create the appropriate event
var event *nostr.Event
var eventErr error
switch req.Kind {
case 1:
// Standard text note
// Create tags
var tags []nostr.Tag
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
case 20:
// Picture post
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey,
req.Title,
req.Content,
mediaURL,
mediaType,
mediaHash,
req.Alt, // Use the alt text if provided
req.Hashtags,
)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
return
}
// Create the appropriate event
var event *nostr.Event
var eventErr error
switch req.Kind {
case 1:
// Standard text note
var tags []nostr.Tag
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
case 20:
// Picture post
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey,
req.Title,
req.Content,
mediaURL,
mediaType,
mediaHash,
req.Alt,
req.Hashtags,
)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
return
}
if eventErr != nil {
a.logger.Error("Failed to create event", zap.Error(eventErr))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
return
}
if eventErr != nil {
a.logger.Error("Failed to create event", zap.Error(eventErr))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
return
}
// Configure relay manager
for _, relay := range bot.Relays {
if relay.Write {
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
a.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
// Get combined relays (bot + global)
combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey)
if err != nil {
a.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Publish to relays with NIP-19 encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Configure relay manager with combined relays
for _, relay := range combinedRelays {
if relay.Write {
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
a.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
// Use the new method that includes NIP-19 encoding
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
if err != nil {
a.logger.Error("Failed to publish event", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
return
}
// Publish to relays with NIP-19 encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Return the encoded event response
c.JSON(http.StatusOK, gin.H{
"message": "Post published successfully",
"event": encodedEvent,
})
// Use the new method that includes NIP-19 encoding
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
if err != nil {
a.logger.Error("Failed to publish event", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
return
}
// Return the encoded event response
c.JSON(http.StatusOK, gin.H{
"message": "Post published successfully",
"event": encodedEvent,
})
}
// Helper function to infer media type from URL
@ -999,4 +1070,102 @@ func extractHashtags(tags []nostr.Tag) []string {
}
}
return hashtags
}
// addGlobalRelay adds a global relay for the current user
func (a *API) addGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the owner pubkey
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.AddGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add global relay"})
a.logger.Error("Failed to add global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// updateGlobalRelay updates a global relay
func (a *API) updateGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the ID and owner pubkey
relay.ID = relayID
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.UpdateGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update global relay"})
a.logger.Error("Failed to update global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// deleteGlobalRelay deletes a global relay
func (a *API) deleteGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
if err := a.globalRelayService.DeleteGlobalRelay(relayID, pubkey); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete global relay"})
a.logger.Error("Failed to delete global relay", zap.Error(err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Global relay deleted"})
}
func (a *API) listGlobalRelays(c *gin.Context) {
pubkey := c.GetString("pubkey")
// Use your existing service method to get global relays
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get global relays"})
a.logger.Error("Failed to get global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}

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()
// Read response for better error reporting
bodyBytes, _ := io.ReadAll(resp.Body)
bodyStr := string(bodyBytes)
// Log the response
u.logger.Info("Received delete response from server",
zap.Int("statusCode", resp.StatusCode),
zap.String("body", bodyStr))
// Check response status
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
errorMsg := fmt.Sprintf("server returned non-success status for delete: %d, body: %s", resp.StatusCode, bodyStr)
// Add helpful diagnostics based on status code
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
errorMsg += " - Authentication error. Check that your keys have delete permission."
case http.StatusNotFound:
errorMsg += " - File not found. It may have already been deleted or never existed."
case http.StatusInternalServerError:
errorMsg += " - Server error. This might be temporary; try again later."
}
return fmt.Errorf(errorMsg)
}
u.logger.Info("File successfully deleted", zap.String("fileHash", fileHash))
return nil
}
@ -377,9 +449,22 @@ func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, er
// WithCustomURL creates a new uploader instance with the specified custom URL.
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
// Ensure the custom URL follows BUD-02 specification by having /upload endpoint
if !strings.HasSuffix(customURL, "/upload") {
customURL = strings.TrimSuffix(customURL, "/") + "/upload"
u.logger.Info("Adding /upload endpoint to custom URL for BUD-02 compliance",
zap.String("original_url", customURL),
zap.String("adjusted_url", customURL))
}
return &Uploader{
serverURL: customURL,
logger: u.logger,
getAuthHeader: u.getAuthHeader,
}
}
// GetServerURL returns the server URL
func (u *Uploader) GetServerURL() string {
return u.serverURL
}

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,
@ -483,4 +512,9 @@ func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
logger: u.logger,
getAuthHeader: u.getAuthHeader,
}
}
// GetServerURL returns the server URL
func (u *Uploader) GetServerURL() string {
return u.serverURL
}

View File

@ -1,24 +1,28 @@
// internal/models/bot.go
package models
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Bot represents a Nostr posting bot
type Bot struct {
ID int64 `db:"id" json:"id"`
Pubkey string `db:"pubkey" json:"pubkey"`
EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Bio string `db:"bio" json:"bio"`
Nip05 string `db:"nip05" json:"nip05"`
ZapAddress string `db:"zap_address" json:"zap_address"`
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
Banner string `db:"banner" json:"banner"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
ID int64 `db:"id" json:"id"`
Pubkey string `db:"pubkey" json:"pubkey"`
EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Bio string `db:"bio" json:"bio"`
Nip05 string `db:"nip05" json:"nip05"`
ZapAddress string `db:"zap_address" json:"zap_address"`
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
Banner string `db:"banner" json:"banner"`
Website sql.NullString `db:"website" json:"website,omitempty"` // Changed to sql.NullString to handle NULL values
// Custom JSON marshaling is handled in MarshalJSON/UnmarshalJSON methods
CreatedAt time.Time `db:"created_at" json:"created_at"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
// The following are not stored in the database
PostConfig *PostConfig `json:"post_config,omitempty"`
@ -48,11 +52,12 @@ type MediaConfig struct {
// Relay represents a Nostr relay configuration
type Relay struct {
ID int64 `db:"id" json:"id"`
BotID int64 `db:"bot_id" json:"-"`
URL string `db:"url" json:"url"`
Read bool `db:"read" json:"read"`
Write bool `db:"write" json:"write"`
ID int64 `db:"id" json:"id"`
BotID int64 `db:"bot_id" json:"-"`
URL string `db:"url" json:"url"`
Read bool `db:"read" json:"read"`
Write bool `db:"write" json:"write"`
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey,omitempty"` // Add this field
}
// Post represents a post made by the bot
@ -65,4 +70,68 @@ type Post struct {
Status string `db:"status" json:"status"` // "pending", "posted", "failed"
CreatedAt time.Time `db:"created_at" json:"created_at"`
Error string `db:"error" json:"error,omitempty"`
}
}
// 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,
"name": bot.Name,
"display_name": bot.DisplayName,
"about": bot.Bio,
"about": bot.Bio,
}
// Add optional fields if they exist
// Add optional fields only if they exist and aren't empty
if bot.Nip05 != "" {
metadata["nip05"] = bot.Nip05
}
@ -46,6 +46,15 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
if bot.Banner != "" {
metadata["banner"] = bot.Banner
}
if bot.ZapAddress != "" {
metadata["lud16"] = bot.ZapAddress // Lightning Address (NIP-57)
}
// Add website field if present
if bot.Website.Valid && bot.Website.String != "" {
metadata["website"] = bot.Website.String
}
// Convert metadata to JSON
content, err := json.Marshal(metadata)
@ -55,7 +64,7 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
// Create the event
ev := nostr.Event{
Kind: 0,
Kind: 0, // User metadata event
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: []nostr.Tag{},
Content: string(content),

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)
@ -136,7 +136,37 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
m.mu.RUnlock()
if len(writeURLs) == 0 {
return nil, fmt.Errorf("no write relays configured")
// Add default relays if none are configured
m.logger.Warn("No write relays configured, adding default relays for posting")
// Try multiple relays for better reliability
defaultRelays := []string{
"wss://freelay.sovbit.host",
"wss://wot.sovbit.host",
"wss://relay.nostr.band",
}
for _, relayURL := range defaultRelays {
err := m.AddRelay(relayURL, true, true)
if err != nil {
m.logger.Warn("Failed to add default relay",
zap.String("relay", relayURL),
zap.Error(err))
// Continue trying other relays
} else {
m.logger.Info("Added default relay", zap.String("relay", relayURL))
}
}
// Refresh the write URLs
m.mu.RLock()
writeURLs = make([]string, len(m.writeURLs))
copy(writeURLs, m.writeURLs)
m.mu.RUnlock()
if len(writeURLs) == 0 {
return nil, fmt.Errorf("no write relays configured")
}
}
// Keep track of successful publishes
@ -160,7 +190,7 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
}
// Create a new context with timeout
publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Publish the event

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)
@ -53,110 +59,113 @@ type ContentPosterWithEncoding func(
// Scheduler manages scheduled content posting
type Scheduler struct {
db *db.DB
cron *cron.Cron
logger *zap.Logger
contentDir string
archiveDir string
nip94Uploader MediaUploader
blossomUploader MediaUploader
postContent ContentPoster
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
botJobs map[int64]cron.EntryID
mu sync.RWMutex
db *db.DB
cron *cron.Cron
logger *zap.Logger
contentDir string
archiveDir string
nip94Uploader MediaUploader
blossomUploader MediaUploader
postContent ContentPoster
postContentEncoded ContentPosterWithEncoding
botJobs map[int64]cron.EntryID
mu sync.RWMutex
globalRelayService RelayService
keyStore *crypto.KeyStore
}
// NewScheduler creates a new content scheduler
func NewScheduler(
db *db.DB,
logger *zap.Logger,
contentDir string,
archiveDir string,
nip94Uploader MediaUploader,
blossomUploader MediaUploader,
postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
db *db.DB,
logger *zap.Logger,
contentDir string,
archiveDir string,
nip94Uploader MediaUploader,
blossomUploader MediaUploader,
postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding,
globalRelayService RelayService,
keyStore *crypto.KeyStore,
) *Scheduler {
if logger == nil {
// Create a default logger
var err error
logger, err = zap.NewProduction()
if err != nil {
logger = zap.NewNop()
}
}
// Create a new cron scheduler with seconds precision
cronScheduler := cron.New(cron.WithSeconds())
return &Scheduler{
db: db,
cron: cronScheduler,
logger: logger,
contentDir: contentDir,
archiveDir: archiveDir,
nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader,
postContent: postContent,
postContentEncoded: postContentEncoded, // Initialize the new field
botJobs: make(map[int64]cron.EntryID),
}
if logger == nil {
// Create a default logger
var err error
logger, err = zap.NewProduction()
if err != nil {
logger = zap.NewNop()
}
}
// Create a new cron scheduler with seconds precision
cronScheduler := cron.New(cron.WithSeconds())
return &Scheduler{
db: db,
cron: cronScheduler, // Use the local variable
logger: logger,
contentDir: contentDir,
archiveDir: archiveDir,
nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader,
postContent: postContent,
postContentEncoded: postContentEncoded,
globalRelayService: globalRelayService,
botJobs: make(map[int64]cron.EntryID),
keyStore: keyStore,
}
}
// Start starts the scheduler
func (s *Scheduler) Start() error {
// Load all bots with enabled post configs
query := `
SELECT b.*, pc.*, mc.*
FROM bots b
JOIN post_config pc ON b.id = pc.bot_id
JOIN media_config mc ON b.id = mc.bot_id
WHERE pc.enabled = 1
`
rows, err := s.db.Queryx(query)
if err != nil {
return fmt.Errorf("failed to load bots: %w", err)
}
defer rows.Close()
// Process each bot
for rows.Next() {
var bot models.Bot
var postConfig models.PostConfig
var mediaConfig models.MediaConfig
// Map the results to our structs
err := rows.Scan(
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName,
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner,
&bot.CreatedAt, &bot.OwnerPubkey,
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes,
&postConfig.PostTemplate, &postConfig.Enabled,
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
)
if err != nil {
s.logger.Error("Failed to scan bot row", zap.Error(err))
continue
}
// Set the associated config
bot.PostConfig = &postConfig
bot.MediaConfig = &mediaConfig
// Schedule the bot
if err := s.ScheduleBot(&bot); err != nil {
s.logger.Error("Failed to schedule bot",
zap.String("name", bot.Name),
zap.Error(err))
}
}
// Start the cron scheduler
s.cron.Start()
return nil
// Load all bots with enabled post configs
query := `
SELECT b.id, b.pubkey, b.name, b.display_name, pc.enabled, pc.interval_minutes, pc.hashtags,
mc.primary_service, mc.fallback_service, mc.nip94_server_url, mc.blossom_server_url
FROM bots b
JOIN post_config pc ON b.id = pc.bot_id
JOIN media_config mc ON b.id = mc.bot_id
WHERE pc.enabled = 1
`
rows, err := s.db.Queryx(query)
if err != nil {
return fmt.Errorf("failed to load bots: %w", err)
}
defer rows.Close()
// Process each bot
for rows.Next() {
var bot models.Bot
var postConfig models.PostConfig
var mediaConfig models.MediaConfig
err := rows.Scan(
&bot.ID, &bot.Pubkey, &bot.Name, &bot.DisplayName,
&postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags,
&mediaConfig.PrimaryService, &mediaConfig.FallbackService,
&mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
)
if err != nil {
s.logger.Error("Failed to scan bot row", zap.Error(err))
continue
}
// Set the associated config
bot.PostConfig = &postConfig
bot.MediaConfig = &mediaConfig
// Schedule the bot
if err := s.ScheduleBot(&bot); err != nil {
s.logger.Error("Failed to schedule bot",
zap.String("name", bot.Name),
zap.Error(err))
}
}
// Start the cron scheduler
s.cron.Start()
return nil
}
// Stop stops the scheduler
@ -230,7 +239,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
return
}
// Select the appropriate uploader
// Get the appropriate uploader for this bot
var uploader MediaUploader
if bot.MediaConfig.PrimaryService == "blossom" {
uploader = s.blossomUploader
@ -276,7 +285,32 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
filename := filepath.Base(contentPath)
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
// Post the content - use encoded version if available, otherwise use the original
// Get the owner pubkey of the bot
var ownerPubkey string
err = s.db.Get(&ownerPubkey, "SELECT owner_pubkey FROM bots WHERE id = ?", bot.ID)
if err != nil {
s.logger.Error("Failed to get bot owner pubkey",
zap.Int64("bot_id", bot.ID),
zap.Error(err))
ownerPubkey = "" // Default to empty if not found
}
// Set up relays if owner found
if ownerPubkey != "" && s.globalRelayService != nil {
// Get combined relays
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(bot.ID, ownerPubkey)
if err == nil && len(combinedRelays) > 0 {
// Use combined relays if available
for _, relay := range combinedRelays {
// Add to both the bot's relays list and the relay manager
if !containsRelay(bot.Relays, relay.URL) {
bot.Relays = append(bot.Relays, relay)
}
}
}
}
// Rest of the function remains the same
var postErr error
if s.postContentEncoded != nil {
@ -448,4 +482,13 @@ func (s *Scheduler) RunNow(botID int64) error {
entry.Job.Run()
return nil
}
func containsRelay(relays []*models.Relay, url string) bool {
for _, relay := range relays {
if relay.URL == url {
return true
}
}
return false
}

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
<script type="module">
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
window.nostrTools = { nip19 };
</script>
</script>
<script src="/assets/js/main.js"></script>
</head>
@ -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>