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,23 +98,36 @@ func main() {
|
|||||||
// Initialize relay manager
|
// Initialize relay manager
|
||||||
relayManager := relay.NewManager(logger)
|
relayManager := relay.NewManager(logger)
|
||||||
|
|
||||||
|
// Initialize global relay service
|
||||||
|
globalRelayService := api.NewGlobalRelayService(database, logger)
|
||||||
|
|
||||||
// Initialize media preparation manager
|
// Initialize media preparation manager
|
||||||
mediaPrep := prepare.NewManager(logger)
|
mediaPrep := prepare.NewManager(logger)
|
||||||
|
|
||||||
|
|
||||||
// Initialize uploaders
|
// Initialize uploaders
|
||||||
// NIP-94 uploader
|
// NIP-94 uploader
|
||||||
nip94Uploader := nip94.NewUploader(
|
nip94Uploader := nip94.NewUploader(
|
||||||
cfg.Media.NIP94.ServerURL,
|
cfg.Media.NIP94.ServerURL,
|
||||||
"", // Download URL will be discovered
|
"", // Download URL will be discovered
|
||||||
nil, // Supported types will be discovered
|
nil, // Supported types will be discovered
|
||||||
logger,
|
logger,
|
||||||
func(url, method string, payload []byte) (string, error) {
|
func(url, method string, payload []byte) (string, error) {
|
||||||
// Replace with a valid bot's public key.
|
// Get an active bot from the database
|
||||||
botPubkey := "your_valid_bot_pubkey_here"
|
var bot struct {
|
||||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
Pubkey string
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no bots found for auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the private key for this bot
|
||||||
|
privkey, err := keyStore.GetPrivateKey(bot.Pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
|
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -124,12 +137,21 @@ func main() {
|
|||||||
cfg.Media.Blossom.ServerURL,
|
cfg.Media.Blossom.ServerURL,
|
||||||
logger,
|
logger,
|
||||||
func(url, method string) (string, error) {
|
func(url, method string) (string, error) {
|
||||||
// Replace with the appropriate bot's public key
|
// Get an active bot from the database
|
||||||
botPubkey := "your_valid_bot_pubkey_here"
|
var bot struct {
|
||||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
Pubkey string
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no bots found for auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the private key for this bot
|
||||||
|
privkey, err := keyStore.GetPrivateKey(bot.Pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return blossom.CreateBlossomAuthHeader(url, method, privkey)
|
return blossom.CreateBlossomAuthHeader(url, method, privkey)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -148,6 +170,7 @@ func main() {
|
|||||||
keyStore,
|
keyStore,
|
||||||
eventManager,
|
eventManager,
|
||||||
relayManager,
|
relayManager,
|
||||||
|
globalRelayService,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -216,9 +239,19 @@ func main() {
|
|||||||
|
|
||||||
content := caption + hashtagStr
|
content := caption + hashtagStr
|
||||||
|
|
||||||
event, err = eventManager.CreateAndSignMediaEvent(
|
// Log mediaURL for debugging
|
||||||
|
logger.Debug("Creating picture event with media URL", zap.String("mediaURL", mediaURL))
|
||||||
|
|
||||||
|
// Extract a title from the caption or use the filename
|
||||||
|
title := caption
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err = eventManager.CreateAndSignPictureEvent(
|
||||||
pubkey,
|
pubkey,
|
||||||
content,
|
title, // Title parameter
|
||||||
|
content, // Description parameter
|
||||||
mediaURL,
|
mediaURL,
|
||||||
contentType,
|
contentType,
|
||||||
mediaHash,
|
mediaHash,
|
||||||
@ -264,17 +297,19 @@ func main() {
|
|||||||
return relayManager.PublishEventWithEncoding(ctx, event)
|
return relayManager.PublishEventWithEncoding(ctx, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize scheduler with both posting functions
|
// Initialize scheduler with both posting functions
|
||||||
posterScheduler := scheduler.NewScheduler(
|
posterScheduler := scheduler.NewScheduler(
|
||||||
database,
|
database,
|
||||||
logger,
|
logger,
|
||||||
cfg.Bot.ContentDir,
|
cfg.Bot.ContentDir,
|
||||||
cfg.Bot.ArchiveDir,
|
cfg.Bot.ArchiveDir,
|
||||||
nip94Uploader,
|
nip94Uploader,
|
||||||
blossomUploader,
|
blossomUploader,
|
||||||
postContentFunc,
|
postContentFunc,
|
||||||
postContentEncodedFunc, // New encoded function
|
postContentEncodedFunc,
|
||||||
)
|
globalRelayService,
|
||||||
|
keyStore,
|
||||||
|
)
|
||||||
|
|
||||||
// Initialize API
|
// Initialize API
|
||||||
apiServer := api.NewAPI(
|
apiServer := api.NewAPI(
|
||||||
@ -282,6 +317,7 @@ func main() {
|
|||||||
botService,
|
botService,
|
||||||
authService,
|
authService,
|
||||||
posterScheduler,
|
posterScheduler,
|
||||||
|
globalRelayService,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start the scheduler
|
// Start the scheduler
|
||||||
|
@ -7,7 +7,7 @@ bot:
|
|||||||
keys_file: "./keys.json"
|
keys_file: "./keys.json"
|
||||||
content_dir: "./content"
|
content_dir: "./content"
|
||||||
archive_dir: "./archive"
|
archive_dir: "./archive"
|
||||||
default_interval: 240 # minutes
|
default_interval: 60 # minutes
|
||||||
|
|
||||||
db:
|
db:
|
||||||
path: "./nostr-poster.db"
|
path: "./nostr-poster.db"
|
||||||
@ -15,10 +15,10 @@ db:
|
|||||||
media:
|
media:
|
||||||
default_service: "blossom"
|
default_service: "blossom"
|
||||||
nip94:
|
nip94:
|
||||||
server_url: "https://files.sovbit.host"
|
server_url: "https://files.sovbit.host" # NIP-96 servers use the base URL
|
||||||
require_auth: true
|
require_auth: true
|
||||||
blossom:
|
blossom:
|
||||||
server_url: "https://cdn.sovbit.host"
|
server_url: "https://cdn.sovbit.host/upload" # Must include '/upload' endpoint for BUD-02 compliance
|
||||||
|
|
||||||
relays:
|
relays:
|
||||||
- url: "wss://freelay.sovbit.host"
|
- url: "wss://freelay.sovbit.host"
|
||||||
|
@ -23,25 +23,29 @@ type BotService struct {
|
|||||||
eventMgr *events.EventManager
|
eventMgr *events.EventManager
|
||||||
relayMgr *relay.Manager
|
relayMgr *relay.Manager
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
globalRelayService *GlobalRelayService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBotService creates a new BotService
|
// NewBotService creates a new BotService
|
||||||
func NewBotService(
|
func NewBotService(
|
||||||
db *db.DB,
|
db *db.DB,
|
||||||
keyStore *crypto.KeyStore,
|
keyStore *crypto.KeyStore,
|
||||||
eventMgr *events.EventManager,
|
eventMgr *events.EventManager,
|
||||||
relayMgr *relay.Manager,
|
relayMgr *relay.Manager,
|
||||||
logger *zap.Logger,
|
globalRelayService *GlobalRelayService,
|
||||||
|
logger *zap.Logger,
|
||||||
) *BotService {
|
) *BotService {
|
||||||
return &BotService{
|
return &BotService{
|
||||||
db: db,
|
db: db,
|
||||||
keyStore: keyStore,
|
keyStore: keyStore,
|
||||||
eventMgr: eventMgr,
|
eventMgr: eventMgr,
|
||||||
relayMgr: relayMgr,
|
relayMgr: relayMgr,
|
||||||
logger: logger,
|
globalRelayService: globalRelayService,
|
||||||
}
|
logger: logger,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// GetPrivateKey returns the private key for the given pubkey from the keystore.
|
// GetPrivateKey returns the private key for the given pubkey from the keystore.
|
||||||
func (s *BotService) GetPrivateKey(pubkey string) (string, error) {
|
func (s *BotService) GetPrivateKey(pubkey string) (string, error) {
|
||||||
return s.keyStore.GetPrivateKey(pubkey)
|
return s.keyStore.GetPrivateKey(pubkey)
|
||||||
@ -248,8 +252,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
Read bool
|
Read bool
|
||||||
Write bool
|
Write bool
|
||||||
}{
|
}{
|
||||||
{"wss://relay.damus.io", true, true},
|
{"wss://freelay.sovbit.host", true, true},
|
||||||
{"wss://nostr.mutinywallet.com", true, true},
|
|
||||||
{"wss://relay.nostr.band", true, true},
|
{"wss://relay.nostr.band", true, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,8 +281,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
bot.PostConfig = postConfig
|
bot.PostConfig = postConfig
|
||||||
bot.MediaConfig = mediaConfig
|
bot.MediaConfig = mediaConfig
|
||||||
bot.Relays = []*models.Relay{
|
bot.Relays = []*models.Relay{
|
||||||
{BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true},
|
{BotID: botID, URL: "wss://freelay.sovbit.host", Read: true, Write: true},
|
||||||
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
|
|
||||||
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
|
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,6 +301,18 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log what we're updating
|
||||||
|
s.logger.Info("Updating bot",
|
||||||
|
zap.Int64("id", bot.ID),
|
||||||
|
zap.String("name", bot.Name),
|
||||||
|
zap.String("display_name", bot.DisplayName),
|
||||||
|
zap.String("bio", bot.Bio),
|
||||||
|
zap.String("nip05", bot.Nip05),
|
||||||
|
zap.String("zap_address", bot.ZapAddress),
|
||||||
|
zap.String("profile_picture", bot.ProfilePicture),
|
||||||
|
zap.String("banner", bot.Banner),
|
||||||
|
zap.Any("website", bot.Website))
|
||||||
|
|
||||||
// We don't update the pubkey or encrypted_privkey
|
// We don't update the pubkey or encrypted_privkey
|
||||||
query := `
|
query := `
|
||||||
UPDATE bots SET
|
UPDATE bots SET
|
||||||
@ -308,14 +322,24 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
nip05 = ?,
|
nip05 = ?,
|
||||||
zap_address = ?,
|
zap_address = ?,
|
||||||
profile_picture = ?,
|
profile_picture = ?,
|
||||||
banner = ?
|
banner = ?,
|
||||||
|
website = ?
|
||||||
WHERE id = ? AND owner_pubkey = ?
|
WHERE id = ? AND owner_pubkey = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Handle nullable website field
|
||||||
|
var websiteVal interface{}
|
||||||
|
if bot.Website.Valid {
|
||||||
|
websiteVal = bot.Website.String
|
||||||
|
} else {
|
||||||
|
websiteVal = nil
|
||||||
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
query,
|
query,
|
||||||
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
|
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
|
||||||
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
||||||
|
websiteVal,
|
||||||
bot.ID, bot.OwnerPubkey,
|
bot.ID, bot.OwnerPubkey,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -500,75 +524,93 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*
|
|||||||
|
|
||||||
// PublishBotProfile publishes a bot's profile to Nostr
|
// PublishBotProfile publishes a bot's profile to Nostr
|
||||||
func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
|
func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
|
||||||
// Get the bot
|
// Get the bot
|
||||||
bot, err := s.GetBotByID(botID, ownerPubkey)
|
bot, err := s.GetBotByID(botID, ownerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and sign the metadata event
|
// Create and sign the metadata event
|
||||||
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create metadata event: %w", err)
|
return fmt.Errorf("failed to create metadata event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up relay connections
|
// Get combined relays (bot + global)
|
||||||
for _, relay := range bot.Relays {
|
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey)
|
||||||
if relay.Write {
|
if err != nil {
|
||||||
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
s.logger.Warn("Failed to get combined relays, using bot relays only",
|
||||||
s.logger.Warn("Failed to add relay",
|
zap.Int64("botID", botID),
|
||||||
zap.String("url", relay.URL),
|
zap.Error(err))
|
||||||
zap.Error(err))
|
combinedRelays = bot.Relays
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the event
|
// Set up relay connections
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
for _, relay := range combinedRelays {
|
||||||
defer cancel()
|
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)
|
// Publish the event
|
||||||
if err != nil {
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
return fmt.Errorf("failed to publish profile: %w", err)
|
defer cancel()
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("Published profile to relays",
|
published, err := s.relayMgr.PublishEvent(ctx, event)
|
||||||
zap.Int64("botID", botID),
|
if err != nil {
|
||||||
zap.Strings("relays", published))
|
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
|
// PublishBotProfileWithEncoding publishes a bot's profile and returns the encoded event
|
||||||
func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) {
|
func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) {
|
||||||
// Get the bot
|
// Get the bot
|
||||||
bot, err := s.GetBotByID(botID, ownerPubkey)
|
bot, err := s.GetBotByID(botID, ownerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and sign the metadata event
|
// Create and sign the metadata event
|
||||||
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create metadata event: %w", err)
|
return nil, fmt.Errorf("failed to create metadata event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up relay connections
|
// Get combined relays (bot + global)
|
||||||
for _, relay := range bot.Relays {
|
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(botID, ownerPubkey)
|
||||||
if relay.Write {
|
if err != nil {
|
||||||
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
s.logger.Warn("Failed to get combined relays, using bot relays only",
|
||||||
s.logger.Warn("Failed to add relay",
|
zap.Int64("botID", botID),
|
||||||
zap.String("url", relay.URL),
|
zap.Error(err))
|
||||||
zap.Error(err))
|
combinedRelays = bot.Relays
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the event with encoding
|
// Set up relay connections
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
for _, relay := range combinedRelays {
|
||||||
defer cancel()
|
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
|
// 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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
@ -29,29 +30,32 @@ type API struct {
|
|||||||
botService *BotService
|
botService *BotService
|
||||||
authService *auth.Service
|
authService *auth.Service
|
||||||
scheduler *scheduler.Scheduler
|
scheduler *scheduler.Scheduler
|
||||||
|
globalRelayService *GlobalRelayService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI creates a new API instance
|
// NewAPI creates a new API instance
|
||||||
func NewAPI(
|
func NewAPI(
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
botService *BotService,
|
botService *BotService,
|
||||||
authService *auth.Service,
|
authService *auth.Service,
|
||||||
scheduler *scheduler.Scheduler,
|
scheduler *scheduler.Scheduler,
|
||||||
|
globalRelayService *GlobalRelayService, // Changed from colon to comma
|
||||||
) *API {
|
) *API {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
api := &API{
|
api := &API{
|
||||||
router: router,
|
router: router,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
botService: botService,
|
botService: botService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
scheduler: scheduler,
|
scheduler: scheduler,
|
||||||
}
|
globalRelayService: globalRelayService, // Added this missing field
|
||||||
|
}
|
||||||
|
|
||||||
// Set up routes
|
// Set up routes
|
||||||
api.setupRoutes()
|
api.setupRoutes()
|
||||||
|
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupRoutes configures the API routes
|
// SetupRoutes configures the API routes
|
||||||
@ -114,6 +118,16 @@ func (a *API) setupRoutes() {
|
|||||||
statsGroup.GET("/:botId", a.getBotStats)
|
statsGroup.GET("/:botId", a.getBotStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global relay management
|
||||||
|
globalRelayGroup := apiGroup.Group("/global-relays")
|
||||||
|
globalRelayGroup.Use(a.requireAuth)
|
||||||
|
{
|
||||||
|
globalRelayGroup.GET("", a.listGlobalRelays)
|
||||||
|
globalRelayGroup.POST("", a.addGlobalRelay)
|
||||||
|
globalRelayGroup.PUT("/:id", a.updateGlobalRelay)
|
||||||
|
globalRelayGroup.DELETE("/:id", a.deleteGlobalRelay)
|
||||||
|
}
|
||||||
|
|
||||||
// Serve the web UI
|
// Serve the web UI
|
||||||
a.router.StaticFile("/", "./web/index.html")
|
a.router.StaticFile("/", "./web/index.html")
|
||||||
a.router.StaticFile("/content.html", "./web/content.html")
|
a.router.StaticFile("/content.html", "./web/content.html")
|
||||||
@ -281,21 +295,39 @@ func (a *API) updateBot(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var bot models.Bot
|
var botUpdate models.Bot
|
||||||
if err := c.ShouldBindJSON(&bot); err != nil {
|
if err := c.ShouldBindJSON(&botUpdate); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
|
a.logger.Error("Invalid bot data", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the received data for debugging
|
||||||
|
a.logger.Info("Received update data",
|
||||||
|
zap.Int64("bot_id", botID),
|
||||||
|
zap.String("name", botUpdate.Name),
|
||||||
|
zap.String("display_name", botUpdate.DisplayName),
|
||||||
|
zap.String("profile_picture", botUpdate.ProfilePicture),
|
||||||
|
zap.String("banner", botUpdate.Banner),
|
||||||
|
zap.Any("website", botUpdate.Website))
|
||||||
|
|
||||||
// Set the ID and owner
|
// Set the ID and owner
|
||||||
bot.ID = botID
|
botUpdate.ID = botID
|
||||||
bot.OwnerPubkey = pubkey
|
botUpdate.OwnerPubkey = pubkey
|
||||||
|
|
||||||
|
// Create SQL NullString for website if it's provided
|
||||||
|
if websiteStr, ok := c.GetPostForm("website"); ok && websiteStr != "" {
|
||||||
|
botUpdate.Website = sql.NullString{
|
||||||
|
String: websiteStr,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the bot
|
// Update the bot
|
||||||
updatedBot, err := a.botService.UpdateBot(&bot)
|
updatedBot, err := a.botService.UpdateBot(&botUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"})
|
|
||||||
a.logger.Error("Failed to update bot", zap.Error(err))
|
a.logger.Error("Failed to update bot", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,8 +504,8 @@ func (a *API) publishBotProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish the profile
|
// Publish the profile with NIP-19 encoding
|
||||||
err = a.botService.PublishBotProfile(botID, pubkey)
|
event, err := a.botService.PublishBotProfileWithEncoding(botID, pubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
|
||||||
a.logger.Error("Failed to publish bot profile", zap.Error(err))
|
a.logger.Error("Failed to publish bot profile", zap.Error(err))
|
||||||
@ -482,6 +514,7 @@ func (a *API) publishBotProfile(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Profile published successfully",
|
"message": "Profile published successfully",
|
||||||
|
"event": event,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -743,6 +776,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
|
|||||||
Filename string `json:"filename" binding:"required"`
|
Filename string `json:"filename" binding:"required"`
|
||||||
Service string `json:"service" binding:"required"`
|
Service string `json:"service" binding:"required"`
|
||||||
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
|
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
|
||||||
|
IsProfile bool `json:"isProfile"` // Optional: indicates this is a profile/banner image
|
||||||
}
|
}
|
||||||
if err := c.BindJSON(&req); err != nil {
|
if err := c.BindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
@ -762,40 +796,60 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
|
|||||||
// Create a new uploader instance that uses the bot's key.
|
// Create a new uploader instance that uses the bot's key.
|
||||||
var uploader scheduler.MediaUploader
|
var uploader scheduler.MediaUploader
|
||||||
if req.Service == "blossom" {
|
if req.Service == "blossom" {
|
||||||
// Get the base Blossom server URL
|
// Get the base Blossom server URL using a fallback chain:
|
||||||
serverURL := bot.MediaConfig.BlossomServerURL
|
// 1. Request URL, 2. Bot's config URL, 3. Global config URL
|
||||||
if req.ServerURL != "" {
|
serverURL := req.ServerURL
|
||||||
serverURL = req.ServerURL
|
if serverURL == "" {
|
||||||
}
|
serverURL = bot.MediaConfig.BlossomServerURL
|
||||||
|
|
||||||
// Log the URL for debugging purposes
|
// If still empty, use the global config URL
|
||||||
a.logger.Info("Creating Blossom uploader with server URL",
|
if serverURL == "" {
|
||||||
zap.String("original_url", serverURL))
|
// Get from global config - you'll need to access this from somewhere
|
||||||
|
// Assuming we have a config accessor, something like:
|
||||||
// According to BUD-02 specification, the upload endpoint should be /upload
|
serverURL = "https://cdn.sovbit.host" // Default from config.yaml as fallback
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
uploader = nip94.NewUploader(
|
||||||
serverURL,
|
serverURL,
|
||||||
"", // Download URL will be discovered
|
"", // Download URL will be discovered
|
||||||
@ -814,9 +868,21 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create appropriate caption and alt text for profile images
|
||||||
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
|
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
|
||||||
altText := caption
|
altText := caption
|
||||||
|
|
||||||
|
// For profile or banner images, set more appropriate caption/alt text
|
||||||
|
if req.IsProfile {
|
||||||
|
if strings.Contains(strings.ToLower(req.Filename), "profile") {
|
||||||
|
caption = fmt.Sprintf("%s's profile picture", bot.Name)
|
||||||
|
altText = caption
|
||||||
|
} else if strings.Contains(strings.ToLower(req.Filename), "banner") {
|
||||||
|
caption = fmt.Sprintf("%s's banner image", bot.Name)
|
||||||
|
altText = caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
|
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Error("Failed to upload to media server",
|
a.logger.Error("Failed to upload to media server",
|
||||||
@ -836,122 +902,127 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
|
|||||||
|
|
||||||
// Updated createManualPost function in routes.go
|
// Updated createManualPost function in routes.go
|
||||||
func (a *API) createManualPost(c *gin.Context) {
|
func (a *API) createManualPost(c *gin.Context) {
|
||||||
pubkey := c.GetString("pubkey")
|
pubkey := c.GetString("pubkey")
|
||||||
botIDStr := c.Param("id")
|
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
if err != nil {
|
||||||
if err != nil {
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the bot belongs to the user
|
// Ensure the bot belongs to the user
|
||||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse request body
|
// Parse request body
|
||||||
var req struct {
|
var req struct {
|
||||||
Kind int `json:"kind" binding:"required"`
|
Kind int `json:"kind" binding:"required"`
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Alt string `json:"alt"` // Added to support alt text for images
|
Alt string `json:"alt"`
|
||||||
Hashtags []string `json:"hashtags"`
|
Hashtags []string `json:"hashtags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.BindJSON(&req); err != nil {
|
if err := c.BindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For kind 20 (picture post), title is required
|
// For kind 20 (picture post), title is required
|
||||||
if req.Kind == 20 && req.Title == "" {
|
if req.Kind == 20 && req.Title == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process content to extract media URLs
|
// Process content to extract media URLs
|
||||||
var mediaURL string
|
var mediaURL string
|
||||||
var mediaType string
|
var mediaType string
|
||||||
var mediaHash string
|
var mediaHash string
|
||||||
|
|
||||||
// Check if content contains URLs
|
// Check if content contains URLs
|
||||||
re := regexp.MustCompile(`https?://[^\s]+`)
|
re := regexp.MustCompile(`https?://[^\s]+`)
|
||||||
matches := re.FindAllString(req.Content, -1)
|
matches := re.FindAllString(req.Content, -1)
|
||||||
|
|
||||||
if len(matches) > 0 {
|
if len(matches) > 0 {
|
||||||
mediaURL = matches[0] // Use the first URL found
|
mediaURL = matches[0] // Use the first URL found
|
||||||
|
mediaType = inferMediaTypeFromURL(mediaURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Try to determine media type
|
// Create the appropriate event
|
||||||
mediaType = inferMediaTypeFromURL(mediaURL)
|
var event *nostr.Event
|
||||||
}
|
var eventErr error
|
||||||
|
|
||||||
// Create the appropriate event
|
switch req.Kind {
|
||||||
var event *nostr.Event
|
case 1:
|
||||||
var eventErr error
|
// Standard text note
|
||||||
|
var tags []nostr.Tag
|
||||||
|
for _, tag := range req.Hashtags {
|
||||||
|
tags = append(tags, nostr.Tag{"t", tag})
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Kind {
|
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
|
||||||
case 1:
|
case 20:
|
||||||
// Standard text note
|
// Picture post
|
||||||
// Create tags
|
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
|
||||||
var tags []nostr.Tag
|
bot.Pubkey,
|
||||||
for _, tag := range req.Hashtags {
|
req.Title,
|
||||||
tags = append(tags, nostr.Tag{"t", tag})
|
req.Content,
|
||||||
}
|
mediaURL,
|
||||||
|
mediaType,
|
||||||
|
mediaHash,
|
||||||
|
req.Alt,
|
||||||
|
req.Hashtags,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
|
if eventErr != nil {
|
||||||
case 20:
|
a.logger.Error("Failed to create event", zap.Error(eventErr))
|
||||||
// Picture post
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
|
||||||
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if eventErr != nil {
|
// Get combined relays (bot + global)
|
||||||
a.logger.Error("Failed to create event", zap.Error(eventErr))
|
combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
|
if err != nil {
|
||||||
return
|
a.logger.Warn("Failed to get combined relays, using bot relays only",
|
||||||
}
|
zap.Int64("botID", botID),
|
||||||
|
zap.Error(err))
|
||||||
|
combinedRelays = bot.Relays
|
||||||
|
}
|
||||||
|
|
||||||
// Configure relay manager
|
// Configure relay manager with combined relays
|
||||||
for _, relay := range bot.Relays {
|
for _, relay := range combinedRelays {
|
||||||
if relay.Write {
|
if relay.Write {
|
||||||
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
||||||
a.logger.Warn("Failed to add relay",
|
a.logger.Warn("Failed to add relay",
|
||||||
zap.String("url", relay.URL),
|
zap.String("url", relay.URL),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish to relays with NIP-19 encoding
|
// Publish to relays with NIP-19 encoding
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Use the new method that includes NIP-19 encoding
|
// Use the new method that includes NIP-19 encoding
|
||||||
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
|
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Error("Failed to publish event", zap.Error(err))
|
a.logger.Error("Failed to publish event", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the encoded event response
|
// Return the encoded event response
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Post published successfully",
|
"message": "Post published successfully",
|
||||||
"event": encodedEvent,
|
"event": encodedEvent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to infer media type from URL
|
// Helper function to infer media type from URL
|
||||||
@ -1000,3 +1071,101 @@ func extractHashtags(tags []nostr.Tag) []string {
|
|||||||
}
|
}
|
||||||
return hashtags
|
return hashtags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addGlobalRelay adds a global relay for the current user
|
||||||
|
func (a *API) addGlobalRelay(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
|
||||||
|
var relay models.Relay
|
||||||
|
if err := c.ShouldBindJSON(&relay); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the owner pubkey
|
||||||
|
relay.OwnerPubkey = pubkey
|
||||||
|
|
||||||
|
if err := a.globalRelayService.AddGlobalRelay(&relay); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add global relay"})
|
||||||
|
a.logger.Error("Failed to add global relay", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated list
|
||||||
|
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
|
||||||
|
a.logger.Error("Failed to get updated global relays", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateGlobalRelay updates a global relay
|
||||||
|
func (a *API) updateGlobalRelay(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var relay models.Relay
|
||||||
|
if err := c.ShouldBindJSON(&relay); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the ID and owner pubkey
|
||||||
|
relay.ID = relayID
|
||||||
|
relay.OwnerPubkey = pubkey
|
||||||
|
|
||||||
|
if err := a.globalRelayService.UpdateGlobalRelay(&relay); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update global relay"})
|
||||||
|
a.logger.Error("Failed to update global relay", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated list
|
||||||
|
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
|
||||||
|
a.logger.Error("Failed to get updated global relays", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteGlobalRelay deletes a global relay
|
||||||
|
func (a *API) deleteGlobalRelay(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.globalRelayService.DeleteGlobalRelay(relayID, pubkey); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete global relay"})
|
||||||
|
a.logger.Error("Failed to delete global relay", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Global relay deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) listGlobalRelays(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
|
||||||
|
// Use your existing service method to get global relays
|
||||||
|
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get global relays"})
|
||||||
|
a.logger.Error("Failed to get global relays", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, relays)
|
||||||
|
}
|
@ -33,11 +33,12 @@ func New(dbPath string) (*DB, error) {
|
|||||||
db.MustExec("PRAGMA foreign_keys=ON;")
|
db.MustExec("PRAGMA foreign_keys=ON;")
|
||||||
|
|
||||||
return &DB{db}, nil
|
return &DB{db}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize creates the database schema if it doesn't exist
|
// Initialize creates the database schema if it doesn't exist
|
||||||
func (db *DB) Initialize() error {
|
func (db *DB) Initialize() error {
|
||||||
// Create bots table
|
// Create bots table with updated fields
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS bots (
|
CREATE TABLE IF NOT EXISTS bots (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -50,6 +51,7 @@ func (db *DB) Initialize() error {
|
|||||||
zap_address TEXT,
|
zap_address TEXT,
|
||||||
profile_picture TEXT,
|
profile_picture TEXT,
|
||||||
banner TEXT,
|
banner TEXT,
|
||||||
|
website TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
owner_pubkey TEXT NOT NULL
|
owner_pubkey TEXT NOT NULL
|
||||||
)`)
|
)`)
|
||||||
@ -57,6 +59,24 @@ func (db *DB) Initialize() error {
|
|||||||
return fmt.Errorf("failed to create bots table: %w", err)
|
return fmt.Errorf("failed to create bots table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if website column exists, add it if it doesn't
|
||||||
|
var columnExists int
|
||||||
|
err = db.Get(&columnExists, `
|
||||||
|
SELECT COUNT(*) FROM pragma_table_info('bots') WHERE name = 'website'
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if website column exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if columnExists == 0 {
|
||||||
|
// Add the website column
|
||||||
|
_, err = db.Exec(`ALTER TABLE bots ADD COLUMN website TEXT`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add website column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create post_config table
|
// Create post_config table
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS post_config (
|
CREATE TABLE IF NOT EXISTS post_config (
|
||||||
@ -119,6 +139,19 @@ func (db *DB) Initialize() error {
|
|||||||
return fmt.Errorf("failed to create posts table: %w", err)
|
return fmt.Errorf("failed to create posts table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS global_relays (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
read BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
write BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
owner_pubkey TEXT NOT NULL,
|
||||||
|
UNIQUE(url, owner_pubkey)
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create global_relays table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,10 +136,19 @@ func getEnhancedContentType(filePath string) (string, error) {
|
|||||||
|
|
||||||
// UploadFile uploads a file to a Blossom server using raw binary data in the request body
|
// UploadFile uploads a file to a Blossom server using raw binary data in the request body
|
||||||
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
||||||
|
// Ensure the URL ends with /upload for BUD-02 compliance
|
||||||
|
serverURL := u.serverURL
|
||||||
|
if !strings.HasSuffix(serverURL, "/upload") {
|
||||||
|
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
|
||||||
|
u.logger.Info("Adding /upload endpoint to URL for BUD-02 compliance",
|
||||||
|
zap.String("original_url", u.serverURL),
|
||||||
|
zap.String("adjusted_url", serverURL))
|
||||||
|
}
|
||||||
|
|
||||||
// Log information about the upload
|
// Log information about the upload
|
||||||
u.logger.Info("Uploading file to Blossom server",
|
u.logger.Info("Uploading file to Blossom server",
|
||||||
zap.String("filePath", filePath),
|
zap.String("filePath", filePath),
|
||||||
zap.String("serverURL", u.serverURL))
|
zap.String("serverURL", serverURL))
|
||||||
|
|
||||||
// Open the file
|
// Open the file
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
@ -181,7 +190,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
|
|
||||||
// Create the request with the file as the raw body
|
// Create the request with the file as the raw body
|
||||||
// This follows BUD-02 which states the endpoint must accept binary data in the body
|
// This follows BUD-02 which states the endpoint must accept binary data in the body
|
||||||
req, err := http.NewRequest("PUT", u.serverURL, file)
|
req, err := http.NewRequest("PUT", serverURL, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@ -206,7 +215,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
|
|
||||||
// Add authorization header if available
|
// Add authorization header if available
|
||||||
if u.getAuthHeader != nil {
|
if u.getAuthHeader != nil {
|
||||||
authHeader, err := u.getAuthHeader(u.serverURL, "PUT")
|
authHeader, err := u.getAuthHeader(serverURL, "PUT")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
||||||
}
|
}
|
||||||
@ -242,13 +251,30 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
if resp.StatusCode != http.StatusOK &&
|
if resp.StatusCode != http.StatusOK &&
|
||||||
resp.StatusCode != http.StatusCreated &&
|
resp.StatusCode != http.StatusCreated &&
|
||||||
resp.StatusCode != http.StatusAccepted {
|
resp.StatusCode != http.StatusAccepted {
|
||||||
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr)
|
errorMsg := fmt.Sprintf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr)
|
||||||
|
|
||||||
|
// Add helpful diagnostics based on status code
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
errorMsg += " - This may indicate an authentication error. Check that your keys are correct and have permission to upload."
|
||||||
|
case http.StatusNotFound:
|
||||||
|
errorMsg += " - The upload endpoint was not found. Ensure the server URL is correct and includes the '/upload' path."
|
||||||
|
case http.StatusRequestEntityTooLarge:
|
||||||
|
errorMsg += " - The file is too large for this server. Try a smaller file or check server limits."
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
errorMsg += " - The server rejected the request. Check that the file format is supported."
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
errorMsg += " - The server encountered an error. This may be temporary; try again later."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", fmt.Errorf(errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
var blossomResp BlossomResponse
|
var blossomResp BlossomResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
// Try to provide useful error message even if JSON parsing fails
|
||||||
|
return "", "", fmt.Errorf("failed to parse server response: %w. Raw response: %s", err, bodyStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for success
|
// Check for success
|
||||||
@ -256,6 +282,18 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message)
|
return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate essential response fields
|
||||||
|
if blossomResp.URL == "" {
|
||||||
|
return "", "", fmt.Errorf("upload succeeded but server did not return a URL. Response: %s", bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if blossomResp.SHA256 == "" {
|
||||||
|
// If hash is missing, use our calculated hash
|
||||||
|
u.logger.Warn("Server did not return a hash, using locally calculated hash",
|
||||||
|
zap.String("local_hash", fileHash))
|
||||||
|
blossomResp.SHA256 = fileHash
|
||||||
|
}
|
||||||
|
|
||||||
// Log the successful response
|
// Log the successful response
|
||||||
u.logger.Info("Upload successful",
|
u.logger.Info("Upload successful",
|
||||||
zap.String("url", blossomResp.URL),
|
zap.String("url", blossomResp.URL),
|
||||||
@ -269,8 +307,21 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
|
|
||||||
// DeleteFile deletes a file from the Blossom server
|
// DeleteFile deletes a file from the Blossom server
|
||||||
func (u *Uploader) DeleteFile(fileHash string) error {
|
func (u *Uploader) DeleteFile(fileHash string) error {
|
||||||
|
// Ensure the base URL is properly formed for Blossom API
|
||||||
|
// For deletes, we need the base URL without the /upload part
|
||||||
|
baseURL := u.serverURL
|
||||||
|
if strings.HasSuffix(baseURL, "/upload") {
|
||||||
|
baseURL = strings.TrimSuffix(baseURL, "/upload")
|
||||||
|
u.logger.Info("Adjusting URL for deletion: removing /upload suffix",
|
||||||
|
zap.String("original_url", u.serverURL),
|
||||||
|
zap.String("adjusted_url", baseURL))
|
||||||
|
}
|
||||||
|
|
||||||
// Create the delete URL
|
// Create the delete URL
|
||||||
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash)
|
||||||
|
u.logger.Info("Preparing to delete file from Blossom server",
|
||||||
|
zap.String("fileHash", fileHash),
|
||||||
|
zap.String("deleteURL", deleteURL))
|
||||||
|
|
||||||
// Create the request
|
// Create the request
|
||||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||||
@ -299,12 +350,33 @@ func (u *Uploader) DeleteFile(fileHash string) error {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 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
|
// Check response status
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
errorMsg := fmt.Sprintf("server returned non-success status for delete: %d, body: %s", resp.StatusCode, bodyStr)
|
||||||
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
|
// Add helpful diagnostics based on status code
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
errorMsg += " - Authentication error. Check that your keys have delete permission."
|
||||||
|
case http.StatusNotFound:
|
||||||
|
errorMsg += " - File not found. It may have already been deleted or never existed."
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
errorMsg += " - Server error. This might be temporary; try again later."
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u.logger.Info("File successfully deleted", zap.String("fileHash", fileHash))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,9 +449,22 @@ func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, er
|
|||||||
|
|
||||||
// WithCustomURL creates a new uploader instance with the specified custom URL.
|
// WithCustomURL creates a new uploader instance with the specified custom URL.
|
||||||
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
||||||
|
// Ensure the custom URL follows BUD-02 specification by having /upload endpoint
|
||||||
|
if !strings.HasSuffix(customURL, "/upload") {
|
||||||
|
customURL = strings.TrimSuffix(customURL, "/") + "/upload"
|
||||||
|
u.logger.Info("Adding /upload endpoint to custom URL for BUD-02 compliance",
|
||||||
|
zap.String("original_url", customURL),
|
||||||
|
zap.String("adjusted_url", customURL))
|
||||||
|
}
|
||||||
|
|
||||||
return &Uploader{
|
return &Uploader{
|
||||||
serverURL: customURL,
|
serverURL: customURL,
|
||||||
logger: u.logger,
|
logger: u.logger,
|
||||||
getAuthHeader: u.getAuthHeader,
|
getAuthHeader: u.getAuthHeader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServerURL returns the server URL
|
||||||
|
func (u *Uploader) GetServerURL() string {
|
||||||
|
return u.serverURL
|
||||||
|
}
|
@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
@ -115,6 +116,17 @@ func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) {
|
|||||||
|
|
||||||
// UploadFile uploads a file to a NIP-96 compatible server
|
// UploadFile uploads a file to a NIP-96 compatible server
|
||||||
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
||||||
|
// Use the server URL as provided - NIP-96 specifies POST to $api_url directly
|
||||||
|
serverURL := u.serverURL
|
||||||
|
// Remove any /upload suffix if it was added incorrectly
|
||||||
|
if strings.HasSuffix(serverURL, "/upload") {
|
||||||
|
serverURL = strings.TrimSuffix(serverURL, "/upload")
|
||||||
|
u.logger.Info("Removing /upload suffix from NIP-96 server URL",
|
||||||
|
zap.String("original_url", u.serverURL),
|
||||||
|
zap.String("adjusted_url", serverURL))
|
||||||
|
}
|
||||||
|
u.logger.Info("Using NIP-96 server URL for upload", zap.String("server_url", serverURL))
|
||||||
|
|
||||||
// Open the file
|
// Open the file
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -197,7 +209,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the request
|
// Create the request
|
||||||
req, err := http.NewRequest("POST", u.serverURL, &requestBody)
|
req, err := http.NewRequest("POST", serverURL, &requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@ -216,7 +228,7 @@ func (u *Uploader) UploadFile(filePath string, caption string, altText string) (
|
|||||||
bodyHash := bodyHasher.Sum(nil)
|
bodyHash := bodyHasher.Sum(nil)
|
||||||
|
|
||||||
// Use the body hash for authentication
|
// Use the body hash for authentication
|
||||||
authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash)
|
authHeader, err := u.getAuthHeader(serverURL, "POST", bodyHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
||||||
}
|
}
|
||||||
@ -383,8 +395,17 @@ func (u *Uploader) waitForProcessing(processingURL string) (string, string, erro
|
|||||||
|
|
||||||
// DeleteFile deletes a file from the server
|
// DeleteFile deletes a file from the server
|
||||||
func (u *Uploader) DeleteFile(fileHash string) error {
|
func (u *Uploader) DeleteFile(fileHash string) error {
|
||||||
|
// Ensure the base URL doesn't have the /upload suffix for deletion
|
||||||
|
baseURL := u.serverURL
|
||||||
|
if strings.HasSuffix(baseURL, "/upload") {
|
||||||
|
baseURL = strings.TrimSuffix(baseURL, "/upload")
|
||||||
|
u.logger.Info("Removing /upload endpoint for deletion",
|
||||||
|
zap.String("original_url", u.serverURL),
|
||||||
|
zap.String("adjusted_url", baseURL))
|
||||||
|
}
|
||||||
|
|
||||||
// Create the delete URL
|
// Create the delete URL
|
||||||
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
deleteURL := fmt.Sprintf("%s/%s", baseURL, fileHash)
|
||||||
|
|
||||||
// Create the request
|
// Create the request
|
||||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||||
@ -475,6 +496,14 @@ func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (
|
|||||||
|
|
||||||
// WithCustomURL creates a new uploader instance with the specified custom URL
|
// WithCustomURL creates a new uploader instance with the specified custom URL
|
||||||
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
||||||
|
// NIP-96 specifies POST to $api_url directly without /upload
|
||||||
|
if strings.HasSuffix(customURL, "/upload") {
|
||||||
|
customURL = strings.TrimSuffix(customURL, "/upload")
|
||||||
|
u.logger.Info("Removing /upload suffix from custom NIP-96 URL",
|
||||||
|
zap.String("original_url", customURL),
|
||||||
|
zap.String("adjusted_url", customURL))
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new uploader with the same configuration but a different URL
|
// Create a new uploader with the same configuration but a different URL
|
||||||
return &Uploader{
|
return &Uploader{
|
||||||
serverURL: customURL,
|
serverURL: customURL,
|
||||||
@ -484,3 +513,8 @@ func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
|||||||
getAuthHeader: u.getAuthHeader,
|
getAuthHeader: u.getAuthHeader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServerURL returns the server URL
|
||||||
|
func (u *Uploader) GetServerURL() string {
|
||||||
|
return u.serverURL
|
||||||
|
}
|
@ -1,24 +1,28 @@
|
|||||||
// internal/models/bot.go
|
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bot represents a Nostr posting bot
|
// Bot represents a Nostr posting bot
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ID int64 `db:"id" json:"id"`
|
||||||
Pubkey string `db:"pubkey" json:"pubkey"`
|
Pubkey string `db:"pubkey" json:"pubkey"`
|
||||||
EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"`
|
EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
DisplayName string `db:"display_name" json:"display_name"`
|
DisplayName string `db:"display_name" json:"display_name"`
|
||||||
Bio string `db:"bio" json:"bio"`
|
Bio string `db:"bio" json:"bio"`
|
||||||
Nip05 string `db:"nip05" json:"nip05"`
|
Nip05 string `db:"nip05" json:"nip05"`
|
||||||
ZapAddress string `db:"zap_address" json:"zap_address"`
|
ZapAddress string `db:"zap_address" json:"zap_address"`
|
||||||
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
|
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
|
||||||
Banner string `db:"banner" json:"banner"`
|
Banner string `db:"banner" json:"banner"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
Website sql.NullString `db:"website" json:"website,omitempty"` // Changed to sql.NullString to handle NULL values
|
||||||
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
|
// 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
|
// The following are not stored in the database
|
||||||
PostConfig *PostConfig `json:"post_config,omitempty"`
|
PostConfig *PostConfig `json:"post_config,omitempty"`
|
||||||
@ -48,11 +52,12 @@ type MediaConfig struct {
|
|||||||
|
|
||||||
// Relay represents a Nostr relay configuration
|
// Relay represents a Nostr relay configuration
|
||||||
type Relay struct {
|
type Relay struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ID int64 `db:"id" json:"id"`
|
||||||
BotID int64 `db:"bot_id" json:"-"`
|
BotID int64 `db:"bot_id" json:"-"`
|
||||||
URL string `db:"url" json:"url"`
|
URL string `db:"url" json:"url"`
|
||||||
Read bool `db:"read" json:"read"`
|
Read bool `db:"read" json:"read"`
|
||||||
Write bool `db:"write" json:"write"`
|
Write bool `db:"write" json:"write"`
|
||||||
|
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey,omitempty"` // Add this field
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post represents a post made by the bot
|
// Post represents a post made by the bot
|
||||||
@ -66,3 +71,67 @@ type Post struct {
|
|||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
Error string `db:"error" json:"error,omitempty"`
|
Error string `db:"error" json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalJSON handles custom JSON marshaling for Bot, especially for sql.NullString fields
|
||||||
|
func (b Bot) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Bot // Create an alias to avoid infinite recursion
|
||||||
|
|
||||||
|
// Create a copy of the bot to modify for JSON
|
||||||
|
bot := &struct {
|
||||||
|
Website interface{} `json:"website,omitempty"`
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(&b),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the sql.NullString field specifically
|
||||||
|
if b.Website.Valid {
|
||||||
|
bot.Website = b.Website.String
|
||||||
|
} else {
|
||||||
|
bot.Website = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(bot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON handles custom JSON unmarshaling for Bot, especially for sql.NullString fields
|
||||||
|
func (b *Bot) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Bot // Create an alias to avoid infinite recursion
|
||||||
|
|
||||||
|
// Create a proxy structure with website as interface{}
|
||||||
|
aux := &struct {
|
||||||
|
Website interface{} `json:"website,omitempty"`
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(b),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle possible types for the website field
|
||||||
|
if aux.Website == nil {
|
||||||
|
b.Website = sql.NullString{Valid: false}
|
||||||
|
} else {
|
||||||
|
switch v := aux.Website.(type) {
|
||||||
|
case string:
|
||||||
|
b.Website = sql.NullString{String: v, Valid: true}
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Handle specific JSON format for sql.NullString
|
||||||
|
if str, ok := v["String"].(string); ok {
|
||||||
|
valid := true
|
||||||
|
if v, ok := v["Valid"].(bool); ok {
|
||||||
|
valid = v
|
||||||
|
}
|
||||||
|
b.Website = sql.NullString{String: str, Valid: valid}
|
||||||
|
}
|
||||||
|
case nil:
|
||||||
|
b.Website = sql.NullString{Valid: false}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported type for Website: %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -27,14 +27,14 @@ func NewEventManager(getPrivateKey func(pubkey string) (string, error)) *EventMa
|
|||||||
|
|
||||||
// CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot
|
// CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot
|
||||||
func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) {
|
func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) {
|
||||||
// Create the metadata structure
|
// Create a comprehensive metadata structure according to NIP-01
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"name": bot.Name,
|
"name": bot.Name,
|
||||||
"display_name": bot.DisplayName,
|
"display_name": bot.DisplayName,
|
||||||
"about": bot.Bio,
|
"about": bot.Bio,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add optional fields if they exist
|
// Add optional fields only if they exist and aren't empty
|
||||||
if bot.Nip05 != "" {
|
if bot.Nip05 != "" {
|
||||||
metadata["nip05"] = bot.Nip05
|
metadata["nip05"] = bot.Nip05
|
||||||
}
|
}
|
||||||
@ -47,6 +47,15 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
|
|||||||
metadata["banner"] = bot.Banner
|
metadata["banner"] = bot.Banner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bot.ZapAddress != "" {
|
||||||
|
metadata["lud16"] = bot.ZapAddress // Lightning Address (NIP-57)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add website field if present
|
||||||
|
if bot.Website.Valid && bot.Website.String != "" {
|
||||||
|
metadata["website"] = bot.Website.String
|
||||||
|
}
|
||||||
|
|
||||||
// Convert metadata to JSON
|
// Convert metadata to JSON
|
||||||
content, err := json.Marshal(metadata)
|
content, err := json.Marshal(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -55,7 +64,7 @@ func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Even
|
|||||||
|
|
||||||
// Create the event
|
// Create the event
|
||||||
ev := nostr.Event{
|
ev := nostr.Event{
|
||||||
Kind: 0,
|
Kind: 0, // User metadata event
|
||||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||||
Tags: []nostr.Tag{},
|
Tags: []nostr.Tag{},
|
||||||
Content: string(content),
|
Content: string(content),
|
||||||
|
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
|
// Determine the appropriate event kind and create the event
|
||||||
if isImage {
|
if isImage {
|
||||||
// Use kind 1 (text note) for images
|
// Log the media URL for debugging
|
||||||
|
p.logger.Info("Creating image post with media URL",
|
||||||
|
zap.String("mediaURL", mediaURL),
|
||||||
|
zap.String("mediaHash", mediaHash),
|
||||||
|
zap.String("contentType", contentType))
|
||||||
|
|
||||||
// Create hashtag string for post content
|
// Create hashtag string for post content
|
||||||
var hashtagStr string
|
var hashtagStr string
|
||||||
if len(hashtags) > 0 {
|
if len(hashtags) > 0 {
|
||||||
@ -111,9 +116,11 @@ func (p *Poster) PostContent(
|
|||||||
|
|
||||||
content := caption + hashtagStr
|
content := caption + hashtagStr
|
||||||
|
|
||||||
event, err = p.eventMgr.CreateAndSignMediaEvent(
|
// Use kind 20 (picture post) for better media compatibility
|
||||||
|
event, err = p.eventMgr.CreateAndSignPictureEvent(
|
||||||
pubkey,
|
pubkey,
|
||||||
content,
|
caption, // Title
|
||||||
|
content, // Description
|
||||||
mediaURL,
|
mediaURL,
|
||||||
contentType,
|
contentType,
|
||||||
mediaHash,
|
mediaHash,
|
||||||
|
@ -88,8 +88,8 @@ func (m *Manager) AddRelay(url string, read, write bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the relay
|
// Connect to the relay with a longer timeout for slow connections
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
relay, err := nostr.RelayConnect(ctx, url)
|
relay, err := nostr.RelayConnect(ctx, url)
|
||||||
@ -136,7 +136,37 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if len(writeURLs) == 0 {
|
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
|
// 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
|
// Create a new context with timeout
|
||||||
publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Publish the event
|
// Publish the event
|
||||||
|
@ -14,9 +14,15 @@ import (
|
|||||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||||
|
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RelayService defines the interface for relay service operations
|
||||||
|
type RelayService interface {
|
||||||
|
GetAllRelaysForPosting(botID int64, ownerPubkey string) ([]*models.Relay, error)
|
||||||
|
}
|
||||||
|
|
||||||
// MediaUploader defines the interface for uploading media
|
// MediaUploader defines the interface for uploading media
|
||||||
type MediaUploader interface {
|
type MediaUploader interface {
|
||||||
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
||||||
@ -53,110 +59,113 @@ type ContentPosterWithEncoding func(
|
|||||||
|
|
||||||
// Scheduler manages scheduled content posting
|
// Scheduler manages scheduled content posting
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
contentDir string
|
contentDir string
|
||||||
archiveDir string
|
archiveDir string
|
||||||
nip94Uploader MediaUploader
|
nip94Uploader MediaUploader
|
||||||
blossomUploader MediaUploader
|
blossomUploader MediaUploader
|
||||||
postContent ContentPoster
|
postContent ContentPoster
|
||||||
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
|
postContentEncoded ContentPosterWithEncoding
|
||||||
botJobs map[int64]cron.EntryID
|
botJobs map[int64]cron.EntryID
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
globalRelayService RelayService
|
||||||
|
keyStore *crypto.KeyStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScheduler creates a new content scheduler
|
// NewScheduler creates a new content scheduler
|
||||||
func NewScheduler(
|
func NewScheduler(
|
||||||
db *db.DB,
|
db *db.DB,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
contentDir string,
|
contentDir string,
|
||||||
archiveDir string,
|
archiveDir string,
|
||||||
nip94Uploader MediaUploader,
|
nip94Uploader MediaUploader,
|
||||||
blossomUploader MediaUploader,
|
blossomUploader MediaUploader,
|
||||||
postContent ContentPoster,
|
postContent ContentPoster,
|
||||||
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
|
postContentEncoded ContentPosterWithEncoding,
|
||||||
|
globalRelayService RelayService,
|
||||||
|
keyStore *crypto.KeyStore,
|
||||||
) *Scheduler {
|
) *Scheduler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
// Create a default logger
|
// Create a default logger
|
||||||
var err error
|
var err error
|
||||||
logger, err = zap.NewProduction()
|
logger, err = zap.NewProduction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new cron scheduler with seconds precision
|
// Create a new cron scheduler with seconds precision
|
||||||
cronScheduler := cron.New(cron.WithSeconds())
|
cronScheduler := cron.New(cron.WithSeconds())
|
||||||
|
|
||||||
return &Scheduler{
|
return &Scheduler{
|
||||||
db: db,
|
db: db,
|
||||||
cron: cronScheduler,
|
cron: cronScheduler, // Use the local variable
|
||||||
logger: logger,
|
logger: logger,
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
archiveDir: archiveDir,
|
archiveDir: archiveDir,
|
||||||
nip94Uploader: nip94Uploader,
|
nip94Uploader: nip94Uploader,
|
||||||
blossomUploader: blossomUploader,
|
blossomUploader: blossomUploader,
|
||||||
postContent: postContent,
|
postContent: postContent,
|
||||||
postContentEncoded: postContentEncoded, // Initialize the new field
|
postContentEncoded: postContentEncoded,
|
||||||
botJobs: make(map[int64]cron.EntryID),
|
globalRelayService: globalRelayService,
|
||||||
}
|
botJobs: make(map[int64]cron.EntryID),
|
||||||
|
keyStore: keyStore,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the scheduler
|
// Start starts the scheduler
|
||||||
func (s *Scheduler) Start() error {
|
func (s *Scheduler) Start() error {
|
||||||
// Load all bots with enabled post configs
|
// Load all bots with enabled post configs
|
||||||
query := `
|
query := `
|
||||||
SELECT b.*, pc.*, mc.*
|
SELECT b.id, b.pubkey, b.name, b.display_name, pc.enabled, pc.interval_minutes, pc.hashtags,
|
||||||
FROM bots b
|
mc.primary_service, mc.fallback_service, mc.nip94_server_url, mc.blossom_server_url
|
||||||
JOIN post_config pc ON b.id = pc.bot_id
|
FROM bots b
|
||||||
JOIN media_config mc ON b.id = mc.bot_id
|
JOIN post_config pc ON b.id = pc.bot_id
|
||||||
WHERE pc.enabled = 1
|
JOIN media_config mc ON b.id = mc.bot_id
|
||||||
`
|
WHERE pc.enabled = 1
|
||||||
|
`
|
||||||
|
|
||||||
rows, err := s.db.Queryx(query)
|
rows, err := s.db.Queryx(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load bots: %w", err)
|
return fmt.Errorf("failed to load bots: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
// Process each bot
|
// Process each bot
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var bot models.Bot
|
var bot models.Bot
|
||||||
var postConfig models.PostConfig
|
var postConfig models.PostConfig
|
||||||
var mediaConfig models.MediaConfig
|
var mediaConfig models.MediaConfig
|
||||||
|
|
||||||
// Map the results to our structs
|
err := rows.Scan(
|
||||||
err := rows.Scan(
|
&bot.ID, &bot.Pubkey, &bot.Name, &bot.DisplayName,
|
||||||
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName,
|
&postConfig.Enabled, &postConfig.IntervalMinutes, &postConfig.Hashtags,
|
||||||
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner,
|
&mediaConfig.PrimaryService, &mediaConfig.FallbackService,
|
||||||
&bot.CreatedAt, &bot.OwnerPubkey,
|
&mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
|
||||||
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes,
|
)
|
||||||
&postConfig.PostTemplate, &postConfig.Enabled,
|
if err != nil {
|
||||||
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
|
s.logger.Error("Failed to scan bot row", zap.Error(err))
|
||||||
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
|
continue
|
||||||
)
|
}
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to scan bot row", zap.Error(err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the associated config
|
// Set the associated config
|
||||||
bot.PostConfig = &postConfig
|
bot.PostConfig = &postConfig
|
||||||
bot.MediaConfig = &mediaConfig
|
bot.MediaConfig = &mediaConfig
|
||||||
|
|
||||||
// Schedule the bot
|
// Schedule the bot
|
||||||
if err := s.ScheduleBot(&bot); err != nil {
|
if err := s.ScheduleBot(&bot); err != nil {
|
||||||
s.logger.Error("Failed to schedule bot",
|
s.logger.Error("Failed to schedule bot",
|
||||||
zap.String("name", bot.Name),
|
zap.String("name", bot.Name),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the cron scheduler
|
// Start the cron scheduler
|
||||||
s.cron.Start()
|
s.cron.Start()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the scheduler
|
// Stop stops the scheduler
|
||||||
@ -230,7 +239,7 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the appropriate uploader
|
// Get the appropriate uploader for this bot
|
||||||
var uploader MediaUploader
|
var uploader MediaUploader
|
||||||
if bot.MediaConfig.PrimaryService == "blossom" {
|
if bot.MediaConfig.PrimaryService == "blossom" {
|
||||||
uploader = s.blossomUploader
|
uploader = s.blossomUploader
|
||||||
@ -276,7 +285,32 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
|
|||||||
filename := filepath.Base(contentPath)
|
filename := filepath.Base(contentPath)
|
||||||
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
|
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
|
||||||
// Post the content - use encoded version if available, otherwise use the original
|
// Get the owner pubkey of the bot
|
||||||
|
var ownerPubkey string
|
||||||
|
err = s.db.Get(&ownerPubkey, "SELECT owner_pubkey FROM bots WHERE id = ?", bot.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get bot owner pubkey",
|
||||||
|
zap.Int64("bot_id", bot.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
ownerPubkey = "" // Default to empty if not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up relays if owner found
|
||||||
|
if ownerPubkey != "" && s.globalRelayService != nil {
|
||||||
|
// Get combined relays
|
||||||
|
combinedRelays, err := s.globalRelayService.GetAllRelaysForPosting(bot.ID, ownerPubkey)
|
||||||
|
if err == nil && len(combinedRelays) > 0 {
|
||||||
|
// Use combined relays if available
|
||||||
|
for _, relay := range combinedRelays {
|
||||||
|
// Add to both the bot's relays list and the relay manager
|
||||||
|
if !containsRelay(bot.Relays, relay.URL) {
|
||||||
|
bot.Relays = append(bot.Relays, relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest of the function remains the same
|
||||||
var postErr error
|
var postErr error
|
||||||
|
|
||||||
if s.postContentEncoded != nil {
|
if s.postContentEncoded != nil {
|
||||||
@ -449,3 +483,12 @@ func (s *Scheduler) RunNow(botID int64) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsRelay(relays []*models.Relay, url string) bool {
|
||||||
|
for _, relay := range relays {
|
||||||
|
if relay.URL == url {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
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">
|
<script type="module">
|
||||||
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
||||||
window.nostrTools = { nip19 };
|
window.nostrTools = { nip19 };
|
||||||
</script>
|
</script>
|
||||||
<script src="/assets/js/main.js"></script>
|
<script src="/assets/js/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -138,6 +138,106 @@
|
|||||||
<!-- Bot cards will be rendered here -->
|
<!-- Bot cards will be rendered here -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="global-relays-section" class="mt-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="#9370DB"
|
||||||
|
class="bi bi-broadcast me-2" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM10 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
|
||||||
|
</svg>
|
||||||
|
Global Relays
|
||||||
|
</h2>
|
||||||
|
<button id="add-global-relay-btn" class="btn btn-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
|
||||||
|
</svg>
|
||||||
|
Add Global Relay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-info-circle-fill me-2" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" />
|
||||||
|
</svg>
|
||||||
|
Global relays are used by all your bots in addition to their individual relays. This follows
|
||||||
|
the
|
||||||
|
<a href="https://github.com/nostr-protocol/nips/blob/master/65.md" target="_blank">NIP-65
|
||||||
|
Outbox Model</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="global-relays-list" class="mt-4">
|
||||||
|
<!-- Relays will be rendered here -->
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<p>Loading global relays...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Global Relay Modal -->
|
||||||
|
<div class="modal fade" id="globalRelayModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#9370DB"
|
||||||
|
class="bi bi-broadcast me-2" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM10 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
|
||||||
|
</svg>
|
||||||
|
Add Global Relay
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="global-relay-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="globalRelayURL" class="form-label">Relay URL</label>
|
||||||
|
<input type="url" class="form-control" id="globalRelayURL" required
|
||||||
|
placeholder="wss://relay.example.com">
|
||||||
|
<div class="form-text">Enter the WebSocket URL of the relay</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Access Type</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="globalRelayRead" checked>
|
||||||
|
<label class="form-check-label" for="globalRelayRead">
|
||||||
|
Read (receive posts from this relay)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="globalRelayWrite" checked>
|
||||||
|
<label class="form-check-label" for="globalRelayWrite">
|
||||||
|
Write (send posts to this relay)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-global-relay-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z" />
|
||||||
|
</svg>
|
||||||
|
Add Relay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -169,6 +269,61 @@
|
|||||||
<input type="text" class="form-control" id="botDisplayName"
|
<input type="text" class="form-control" id="botDisplayName"
|
||||||
placeholder="e.g., My Art Posting Bot">
|
placeholder="e.g., My Art Posting Bot">
|
||||||
</div>
|
</div>
|
||||||
|
<hr class="border-secondary">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Profile Picture</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="file" class="form-control" id="botProfilePicture" accept="image/*">
|
||||||
|
<button class="btn btn-secondary" type="button" id="uploadProfilePicture">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
|
||||||
|
<path
|
||||||
|
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
|
||||||
|
</svg>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="profilePicturePreview" class="d-none mt-2">
|
||||||
|
<img id="profilePreviewImage" class="img-thumbnail" style="max-height: 150px;"
|
||||||
|
alt="Profile preview">
|
||||||
|
<div class="mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
id="removeProfilePicture">Remove</button>
|
||||||
|
<span id="profilePictureUrl" class="ms-2 text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Select a profile picture for your bot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Banner Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="file" class="form-control" id="botBanner" accept="image/*">
|
||||||
|
<button class="btn btn-secondary" type="button" id="uploadBanner">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
|
||||||
|
<path
|
||||||
|
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
|
||||||
|
</svg>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="bannerPreview" class="d-none mt-2">
|
||||||
|
<img id="bannerPreviewImage" class="img-thumbnail"
|
||||||
|
style="max-width: 100%; max-height: 100px;" alt="Banner preview">
|
||||||
|
<div class="mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
id="removeBanner">Remove</button>
|
||||||
|
<span id="bannerUrl" class="ms-2 text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Select a banner image for your bot</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="botBio" class="form-label">Bio</label>
|
<label for="botBio" class="form-label">Bio</label>
|
||||||
<textarea class="form-control" id="botBio" rows="3"
|
<textarea class="form-control" id="botBio" rows="3"
|
||||||
@ -180,6 +335,12 @@
|
|||||||
placeholder="e.g., bot@yourdomain.com">
|
placeholder="e.g., bot@yourdomain.com">
|
||||||
<div class="form-text">Optional. Used for verification.</div>
|
<div class="form-text">Optional. Used for verification.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botWebsite" class="form-label">Website</label>
|
||||||
|
<input type="url" class="form-control" id="botWebsite"
|
||||||
|
placeholder="e.g., https://yourwebsite.com">
|
||||||
|
<div class="form-text">Optional. Website URL for the bot.</div>
|
||||||
|
</div>
|
||||||
<hr class="border-secondary">
|
<hr class="border-secondary">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -243,6 +404,7 @@
|
|||||||
<label for="botSettingsDisplayName" class="form-label">Display Name</label>
|
<label for="botSettingsDisplayName" class="form-label">Display Name</label>
|
||||||
<input type="text" class="form-control" id="botSettingsDisplayName">
|
<input type="text" class="form-control" id="botSettingsDisplayName">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="botSettingsBio" class="form-label">Bio</label>
|
<label for="botSettingsBio" class="form-label">Bio</label>
|
||||||
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
|
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
|
||||||
@ -256,8 +418,87 @@
|
|||||||
<input type="text" class="form-control" id="botSettingsZap">
|
<input type="text" class="form-control" id="botSettingsZap">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<div class="mb-3">
|
||||||
|
<label for="botWebsite" class="form-label">Website</label>
|
||||||
|
<input type="url" class="form-control" id="botWebsite"
|
||||||
|
placeholder="e.g., https://yourwebsite.com">
|
||||||
|
<div class="form-text">Optional. Website URL for the bot.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Profile Picture</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="file" class="form-control" id="botSettingsProfilePicture" accept="image/*">
|
||||||
|
<button class="btn btn-secondary" type="button" id="uploadSettingsProfilePicture">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
|
||||||
|
<path
|
||||||
|
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
|
||||||
|
</svg>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="settingsProfilePicturePreview" class="mt-2">
|
||||||
|
<div id="settingsProfilePictureEmpty" class="alert alert-secondary">No profile picture
|
||||||
|
set</div>
|
||||||
|
<div id="settingsProfilePictureContainer" class="d-none">
|
||||||
|
<img id="settingsProfilePreviewImage" class="img-thumbnail"
|
||||||
|
style="max-height: 150px;" alt="Profile preview">
|
||||||
|
<div class="mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
id="removeSettingsProfilePicture">Remove</button>
|
||||||
|
<span id="settingsProfilePictureUrl" class="ms-2 text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Banner Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="file" class="form-control" id="botSettingsBanner" accept="image/*">
|
||||||
|
<button class="btn btn-secondary" type="button" id="uploadSettingsBanner">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z" />
|
||||||
|
<path
|
||||||
|
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z" />
|
||||||
|
</svg>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="settingsBannerPreview" class="mt-2">
|
||||||
|
<div id="settingsBannerEmpty" class="alert alert-secondary">No banner image set</div>
|
||||||
|
<div id="settingsBannerContainer" class="d-none">
|
||||||
|
<img id="settingsBannerPreviewImage" class="img-thumbnail"
|
||||||
|
style="max-width: 100%; max-height: 100px;" alt="Banner preview">
|
||||||
|
<div class="mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||||
|
id="removeSettingsBanner">Remove</button>
|
||||||
|
<span id="settingsBannerUrl" class="ms-2 text-muted small"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<!-- Add Profile Publishing Button -->
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<button type="button" class="btn btn-primary" id="publishProfileBtn"
|
||||||
|
onclick="publishBotProfile(document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id'))">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-broadcast-pin me-1" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8z" />
|
||||||
|
</svg>
|
||||||
|
Publish Profile to Nostr
|
||||||
|
</button>
|
||||||
|
<div class="form-text">Publish this bot's profile to Nostr relays (must be done after
|
||||||
|
changes)</div>
|
||||||
|
</div>
|
||||||
<!-- Post config fields -->
|
<!-- Post config fields -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>
|
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user