V1. need to add commenting and a post feed for bots.
This commit is contained in:
parent
f3680ef30c
commit
341ffbc33d
243
CLAUDE.md
Normal file
243
CLAUDE.md
Normal 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
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
139
internal/api/global_relay_service.go
Normal file
139
internal/api/global_relay_service.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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),
|
||||
|
80
internal/nostr/nip65/outbox.go
Normal file
80
internal/nostr/nip65/outbox.go
Normal 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
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
245
web/index.html
245
web/index.html
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user