Project framework is mostly done. see todo
This commit is contained in:
parent
90e4280b2c
commit
964f812897
144
README.md
144
README.md
@ -0,0 +1,144 @@
|
||||
# Nostr Poster
|
||||
|
||||
An automated content posting bot for Nostr networks. This tool allows you to schedule regular posting of images and videos to Nostr relays, supporting both NIP-94 and Blossom upload services.
|
||||
|
||||
|
||||
### Core Implementation
|
||||
- [ ] replace hex format
|
||||
|
||||
### Essential Settings
|
||||
- [ ] Make enable button functional
|
||||
- [ ] Bot settings page with:
|
||||
- [ ] Bio
|
||||
- [ ] NIP-05
|
||||
- [ ] Username/display name
|
||||
- [ ] Zap address
|
||||
- [ ] PFP/banner upload
|
||||
- [ ] Posting interval controls
|
||||
- [ ] Content album selection
|
||||
|
||||
### Content System
|
||||
- [ ] Create upload/organization page
|
||||
- [ ] Implement manual post interface:
|
||||
- [ ] Text
|
||||
- [ ] Media upload
|
||||
|
||||
### Bot Interaction
|
||||
- [ ] Develop basic bot feed:
|
||||
- [ ] Display Comments
|
||||
- [ ] Reply
|
||||
|
||||
### Validation
|
||||
- [ ] Test NSEC key import
|
||||
- [ ] Test manual posts:
|
||||
- [ ] Text only
|
||||
- [ ] Media upload
|
||||
- [ ] Verify bot reply works
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Media Management**: Upload content to either NIP-94/96 compatible servers or Blossom storage servers
|
||||
- **Scheduled Posting**: Configure posting intervals for automated content sharing
|
||||
- **Multiple Bot Support**: Manage multiple bot identities, each with their own keypair
|
||||
- **Keypair Management**: Create new keypairs or import existing ones
|
||||
- **Relay Configuration**: Configure which relays to publish to for each bot
|
||||
- **Profile Management**: Set up and publish bot profiles (name, bio, avatar, etc.)
|
||||
- **Content Archiving**: Posted content gets archived to avoid duplicate posts
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- Go 1.18 or higher
|
||||
- SQLite 3
|
||||
|
||||
### Building from Source
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/nostr-poster.git
|
||||
cd nostr-poster
|
||||
```
|
||||
|
||||
2. Build the application:
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The application can be configured via a YAML file or environment variables. By default, the config file is located at `/config.yaml`.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```yaml
|
||||
app_name: "Nostr Poster"
|
||||
server_port: 8080
|
||||
log_level: "info"
|
||||
|
||||
bot:
|
||||
keys_file: "./keys.json"
|
||||
content_dir: "./content"
|
||||
archive_dir: "./archive"
|
||||
default_interval: 60 # minutes
|
||||
|
||||
db:
|
||||
path: "./nostr-poster.db"
|
||||
|
||||
media:
|
||||
default_service: "nip94"
|
||||
nip94:
|
||||
server_url: "https://nostr.build/api/upload/nostr"
|
||||
require_auth: true
|
||||
blossom:
|
||||
server_url: "https://blossom.example.com"
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Set up a Bot
|
||||
|
||||
1. Navigate to the web interface at `http://localhost:8765`
|
||||
2. Log in with your Nostr extension (NIP-07)
|
||||
3. Create a new bot by providing a name and optional keypair
|
||||
4. Configure the posting schedule, media upload service, and relays
|
||||
5. Add content to the bot's content directory
|
||||
6. Enable the bot to start automated posting
|
||||
|
||||
### Manual Posting
|
||||
|
||||
You can also trigger a post manually through the web interface
|
||||
|
||||
## NIPs Supported
|
||||
|
||||
- NIP-01: Basic protocol flow
|
||||
- NIP-07: Browser extension authentication
|
||||
- NIP-55: Android signer application (soon TM)
|
||||
- NIP-94: File metadata
|
||||
- NIP-96: HTTP file storage
|
||||
- NIP-98: HTTP authentication
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
BIN
build/bin/nostr-poster
Executable file
BIN
build/bin/nostr-poster
Executable file
Binary file not shown.
25
build/config/config.yaml
Normal file
25
build/config/config.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
app_name: "Nostr Poster"
|
||||
server_port: 8765
|
||||
log_level: "info"
|
||||
|
||||
bot:
|
||||
keys_file: "./keys.json"
|
||||
content_dir: "./content"
|
||||
archive_dir: "./archive"
|
||||
default_interval: 240 # minutes
|
||||
|
||||
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"
|
||||
|
||||
relays:
|
||||
- url: "wss://freelay.sovbit.host"
|
||||
read: true
|
||||
write: true
|
BIN
build/db/nostr-poster.db
Normal file
BIN
build/db/nostr-poster.db
Normal file
Binary file not shown.
BIN
build/db/nostr-poster.db-shm
Normal file
BIN
build/db/nostr-poster.db-shm
Normal file
Binary file not shown.
BIN
build/db/nostr-poster.db-wal
Normal file
BIN
build/db/nostr-poster.db-wal
Normal file
Binary file not shown.
193
cmd/server/main.go
Normal file
193
cmd/server/main.go
Normal file
@ -0,0 +1,193 @@
|
||||
// cmd/server/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/api"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/config"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/media/prepare"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/poster"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
configPath := flag.String("config", "", "Path to config file")
|
||||
dbPath := flag.String("db", "", "Path to database file")
|
||||
port := flag.Int("port", 0, "Port to listen on")
|
||||
password := flag.String("password", "", "Password for encrypting private keys")
|
||||
flag.Parse()
|
||||
|
||||
// Setup logger
|
||||
logConfig := zap.NewProductionConfig()
|
||||
logConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
logger, err := logConfig.Build()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
// Load config
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to load config", zap.Error(err))
|
||||
}
|
||||
|
||||
// Override config with command line flags if provided
|
||||
if *dbPath != "" {
|
||||
cfg.DB.Path = *dbPath
|
||||
}
|
||||
if *port != 0 {
|
||||
cfg.ServerPort = *port
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
if err := utils.EnsureDir(cfg.Bot.ContentDir); err != nil {
|
||||
logger.Fatal("Failed to create content directory", zap.Error(err))
|
||||
}
|
||||
if err := utils.EnsureDir(cfg.Bot.ArchiveDir); err != nil {
|
||||
logger.Fatal("Failed to create archive directory", zap.Error(err))
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
database, err := db.New(cfg.DB.Path)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to connect to database", zap.Error(err))
|
||||
}
|
||||
if err := database.Initialize(); err != nil {
|
||||
logger.Fatal("Failed to initialize database", zap.Error(err))
|
||||
}
|
||||
|
||||
// Initialize key store
|
||||
keyPassword := *password
|
||||
if keyPassword == "" {
|
||||
// Use a default password or prompt for one
|
||||
// In a production environment, you'd want to handle this more securely
|
||||
keyPassword = "nostr-poster-default-password"
|
||||
}
|
||||
keyStore, err := crypto.NewKeyStore(cfg.Bot.KeysFile, keyPassword)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to initialize key store", zap.Error(err))
|
||||
}
|
||||
|
||||
// Initialize event manager
|
||||
eventManager := events.NewEventManager(func(pubkey string) (string, error) {
|
||||
return keyStore.GetPrivateKey(pubkey)
|
||||
})
|
||||
|
||||
// Initialize relay manager
|
||||
relayManager := relay.NewManager(logger)
|
||||
|
||||
// Initialize media preparation manager
|
||||
mediaPrep := prepare.NewManager(logger)
|
||||
|
||||
// Initialize uploaders
|
||||
// NIP-94 uploader
|
||||
nip94Uploader := nip94.NewUploader(
|
||||
cfg.Media.NIP94.ServerURL,
|
||||
"", // Download URL will be discovered
|
||||
nil, // Supported types will be discovered
|
||||
logger,
|
||||
func(url, method string, payload []byte) (string, error) {
|
||||
// Get the private key for the bot
|
||||
// This is a placeholder - in the real implementation
|
||||
// you would need to determine which bot's key to use
|
||||
// based on the context of the upload
|
||||
botPubkey := ""
|
||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
|
||||
},
|
||||
)
|
||||
|
||||
// Blossom uploader
|
||||
blossomUploader := blossom.NewUploader(
|
||||
cfg.Media.Blossom.ServerURL,
|
||||
logger,
|
||||
func(url, method string) (string, error) {
|
||||
// Get the private key for the bot
|
||||
// This is a placeholder - in the real implementation
|
||||
// you would need to determine which bot's key to use
|
||||
// based on the context of the upload
|
||||
botPubkey := ""
|
||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return blossom.CreateBlossomAuthHeader(url, method, privkey)
|
||||
},
|
||||
)
|
||||
|
||||
// Initialize authentication service
|
||||
authService := auth.NewService(
|
||||
database,
|
||||
logger,
|
||||
keyPassword, // Use the same password for simplicity
|
||||
24*time.Hour, // Token duration
|
||||
)
|
||||
|
||||
// Initialize bot service
|
||||
botService := api.NewBotService(
|
||||
database,
|
||||
keyStore,
|
||||
eventManager,
|
||||
relayManager,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Create post content function
|
||||
postContentFunc := poster.CreatePostContentFunc(
|
||||
eventManager,
|
||||
relayManager,
|
||||
mediaPrep,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Initialize scheduler
|
||||
posterScheduler := scheduler.NewScheduler(
|
||||
database,
|
||||
logger,
|
||||
cfg.Bot.ContentDir,
|
||||
cfg.Bot.ArchiveDir,
|
||||
nip94Uploader,
|
||||
blossomUploader,
|
||||
postContentFunc,
|
||||
)
|
||||
|
||||
// Initialize API
|
||||
apiServer := api.NewAPI(
|
||||
logger,
|
||||
botService,
|
||||
authService,
|
||||
posterScheduler,
|
||||
)
|
||||
|
||||
// Start the scheduler
|
||||
if err := posterScheduler.Start(); err != nil {
|
||||
logger.Error("Failed to start scheduler", zap.Error(err))
|
||||
}
|
||||
|
||||
// Start the server
|
||||
addr := fmt.Sprintf(":%d", cfg.ServerPort)
|
||||
logger.Info("Starting server", zap.String("address", addr))
|
||||
if err := apiServer.Run(addr); err != nil {
|
||||
logger.Fatal("Failed to start server", zap.Error(err))
|
||||
}
|
||||
}
|
25
config/config.yaml
Normal file
25
config/config.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
app_name: "Nostr Poster"
|
||||
server_port: 8765
|
||||
log_level: "info"
|
||||
|
||||
bot:
|
||||
keys_file: "./keys.json"
|
||||
content_dir: "./content"
|
||||
archive_dir: "./archive"
|
||||
default_interval: 240 # minutes
|
||||
|
||||
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"
|
||||
|
||||
relays:
|
||||
- url: "wss://freelay.sovbit.host"
|
||||
read: true
|
||||
write: true
|
66
go.mod
Normal file
66
go.mod
Normal file
@ -0,0 +1,66 @@
|
||||
module git.sovbit.dev/Enki/nostr-poster
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nbd-wtf/go-nostr v0.50.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.19.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.36.2 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
159
go.sum
Normal file
159
go.sum
Normal file
@ -0,0 +1,159 @@
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nbd-wtf/go-nostr v0.50.0 h1:MgL/HPnWSTb5BFCL9RuzYQQpMrTi67MvHem4nWFn47E=
|
||||
github.com/nbd-wtf/go-nostr v0.50.0/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
|
||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
||||
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
511
internal/api/bot_service.go
Normal file
511
internal/api/bot_service.go
Normal file
@ -0,0 +1,511 @@
|
||||
// internal/api/bot_service.go
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// BotService provides functionality for managing bots
|
||||
type BotService struct {
|
||||
db *db.DB
|
||||
keyStore *crypto.KeyStore
|
||||
eventMgr *events.EventManager
|
||||
relayMgr *relay.Manager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewBotService creates a new BotService
|
||||
func NewBotService(
|
||||
db *db.DB,
|
||||
keyStore *crypto.KeyStore,
|
||||
eventMgr *events.EventManager,
|
||||
relayMgr *relay.Manager,
|
||||
logger *zap.Logger,
|
||||
) *BotService {
|
||||
return &BotService{
|
||||
db: db,
|
||||
keyStore: keyStore,
|
||||
eventMgr: eventMgr,
|
||||
relayMgr: relayMgr,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUserBots lists all bots owned by a user
|
||||
func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) {
|
||||
query := `
|
||||
SELECT b.* FROM bots b
|
||||
WHERE b.owner_pubkey = ?
|
||||
ORDER BY b.created_at DESC
|
||||
`
|
||||
|
||||
var bots []*models.Bot
|
||||
err := s.db.Select(&bots, query, ownerPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list bots: %w", err)
|
||||
}
|
||||
|
||||
// Load associated data for each bot
|
||||
for _, bot := range bots {
|
||||
if err := s.loadBotRelatedData(bot); err != nil {
|
||||
s.logger.Warn("Failed to load related data for bot",
|
||||
zap.Int64("botID", bot.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return bots, nil
|
||||
}
|
||||
|
||||
// GetBotByID gets a bot by ID and verifies ownership
|
||||
func (s *BotService) GetBotByID(botID int64, ownerPubkey string) (*models.Bot, error) {
|
||||
query := `
|
||||
SELECT b.* FROM bots b
|
||||
WHERE b.id = ? AND b.owner_pubkey = ?
|
||||
`
|
||||
|
||||
var bot models.Bot
|
||||
err := s.db.Get(&bot, query, botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bot: %w", err)
|
||||
}
|
||||
|
||||
// Load associated data
|
||||
if err := s.loadBotRelatedData(&bot); err != nil {
|
||||
return &bot, fmt.Errorf("failed to load related data: %w", err)
|
||||
}
|
||||
|
||||
return &bot, nil
|
||||
}
|
||||
|
||||
// CreateBot creates a new bot
|
||||
func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
||||
// Start a transaction
|
||||
tx, err := s.db.Beginx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Check if we need to generate a keypair
|
||||
if bot.Pubkey == "" {
|
||||
// Generate a new keypair
|
||||
pubkey, privkey, err := s.keyStore.GenerateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate keypair: %w", err)
|
||||
}
|
||||
|
||||
bot.Pubkey = pubkey
|
||||
bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore
|
||||
} else if bot.EncryptedPrivkey != "" {
|
||||
// Import the provided keypair
|
||||
if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil {
|
||||
return nil, fmt.Errorf("failed to import keypair: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("either provide both pubkey and privkey or none for auto-generation")
|
||||
}
|
||||
|
||||
// Set created time
|
||||
bot.CreatedAt = time.Now()
|
||||
|
||||
// Insert the bot
|
||||
query := `
|
||||
INSERT INTO bots (
|
||||
pubkey, encrypted_privkey, name, display_name, bio, nip05, zap_address,
|
||||
profile_picture, banner, created_at, owner_pubkey
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
result, err := tx.Exec(
|
||||
query,
|
||||
bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio,
|
||||
bot.Nip05, bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
||||
bot.CreatedAt, bot.OwnerPubkey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to insert bot: %w", err)
|
||||
}
|
||||
|
||||
// Get the inserted ID
|
||||
botID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get inserted ID: %w", err)
|
||||
}
|
||||
bot.ID = botID
|
||||
|
||||
// Create default post config
|
||||
postConfig := &models.PostConfig{
|
||||
BotID: botID,
|
||||
Hashtags: "[]",
|
||||
IntervalMinutes: 60,
|
||||
PostTemplate: "",
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
postConfigQuery := `
|
||||
INSERT INTO post_config (
|
||||
bot_id, hashtags, interval_minutes, post_template, enabled
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = tx.Exec(
|
||||
postConfigQuery,
|
||||
postConfig.BotID, postConfig.Hashtags, postConfig.IntervalMinutes,
|
||||
postConfig.PostTemplate, postConfig.Enabled,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to insert post config: %w", err)
|
||||
}
|
||||
|
||||
// Create default media config
|
||||
mediaConfig := &models.MediaConfig{
|
||||
BotID: botID,
|
||||
PrimaryService: "nip94",
|
||||
FallbackService: "blossom",
|
||||
Nip94ServerURL: "",
|
||||
BlossomServerURL: "",
|
||||
}
|
||||
|
||||
mediaConfigQuery := `
|
||||
INSERT INTO media_config (
|
||||
bot_id, primary_service, fallback_service, nip94_server_url, blossom_server_url
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = tx.Exec(
|
||||
mediaConfigQuery,
|
||||
mediaConfig.BotID, mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
||||
mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to insert media config: %w", err)
|
||||
}
|
||||
|
||||
// Add default relays
|
||||
defaultRelays := []struct {
|
||||
URL string
|
||||
Read bool
|
||||
Write bool
|
||||
}{
|
||||
{"wss://relay.damus.io", true, true},
|
||||
{"wss://nostr.mutinywallet.com", true, true},
|
||||
{"wss://relay.nostr.band", true, true},
|
||||
}
|
||||
|
||||
relayQuery := `
|
||||
INSERT INTO relays (bot_id, url, read, write)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
for _, relay := range defaultRelays {
|
||||
_, err = tx.Exec(relayQuery, botID, relay.URL, relay.Read, relay.Write)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to insert relay: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
// Load associated data
|
||||
bot.PostConfig = postConfig
|
||||
bot.MediaConfig = mediaConfig
|
||||
bot.Relays = []*models.Relay{
|
||||
{BotID: botID, URL: "wss://relay.damus.io", Read: true, Write: true},
|
||||
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
|
||||
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
|
||||
}
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// UpdateBot updates an existing bot
|
||||
func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
|
||||
// Check if the bot exists and belongs to the owner
|
||||
_, err := s.GetBotByID(bot.ID, bot.OwnerPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// We don't update the pubkey or encrypted_privkey
|
||||
query := `
|
||||
UPDATE bots SET
|
||||
name = ?,
|
||||
display_name = ?,
|
||||
bio = ?,
|
||||
nip05 = ?,
|
||||
zap_address = ?,
|
||||
profile_picture = ?,
|
||||
banner = ?
|
||||
WHERE id = ? AND owner_pubkey = ?
|
||||
`
|
||||
|
||||
_, err = s.db.Exec(
|
||||
query,
|
||||
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
|
||||
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
||||
bot.ID, bot.OwnerPubkey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update bot: %w", err)
|
||||
}
|
||||
|
||||
// Get the updated bot
|
||||
updatedBot, err := s.GetBotByID(bot.ID, bot.OwnerPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve updated bot: %w", err)
|
||||
}
|
||||
|
||||
return updatedBot, nil
|
||||
}
|
||||
|
||||
// DeleteBot deletes a bot
|
||||
func (s *BotService) DeleteBot(botID int64, ownerPubkey string) error {
|
||||
// Check if the bot exists and belongs to the owner
|
||||
_, err := s.GetBotByID(botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
tx, err := s.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete the bot (cascade will handle related tables)
|
||||
query := `DELETE FROM bots WHERE id = ? AND owner_pubkey = ?`
|
||||
_, err = tx.Exec(query, botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bot: %w", err)
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBotConfig updates a bot's configuration
|
||||
func (s *BotService) UpdateBotConfig(
|
||||
botID int64,
|
||||
ownerPubkey string,
|
||||
postConfig *models.PostConfig,
|
||||
mediaConfig *models.MediaConfig,
|
||||
) error {
|
||||
// Check if the bot exists and belongs to the owner
|
||||
_, err := s.GetBotByID(botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
tx, err := s.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update post config if provided
|
||||
if postConfig != nil {
|
||||
query := `
|
||||
UPDATE post_config SET
|
||||
hashtags = ?,
|
||||
interval_minutes = ?,
|
||||
post_template = ?,
|
||||
enabled = ?
|
||||
WHERE bot_id = ?
|
||||
`
|
||||
|
||||
_, err = tx.Exec(
|
||||
query,
|
||||
postConfig.Hashtags, postConfig.IntervalMinutes,
|
||||
postConfig.PostTemplate, postConfig.Enabled,
|
||||
botID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update post config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update media config if provided
|
||||
if mediaConfig != nil {
|
||||
query := `
|
||||
UPDATE media_config SET
|
||||
primary_service = ?,
|
||||
fallback_service = ?,
|
||||
nip94_server_url = ?,
|
||||
blossom_server_url = ?
|
||||
WHERE bot_id = ?
|
||||
`
|
||||
|
||||
_, err = tx.Exec(
|
||||
query,
|
||||
mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
||||
mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL,
|
||||
botID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update media config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBotRelays gets the relays for a bot
|
||||
func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Relay, error) {
|
||||
// Check if the bot exists and belongs to the owner
|
||||
_, err := s.GetBotByID(botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// Get the relays
|
||||
query := `
|
||||
SELECT id, bot_id, url, read, write
|
||||
FROM relays
|
||||
WHERE bot_id = ?
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
var relays []*models.Relay
|
||||
err = s.db.Select(&relays, query, botID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get relays: %w", err)
|
||||
}
|
||||
|
||||
return relays, nil
|
||||
}
|
||||
|
||||
// UpdateBotRelays updates the relays for a bot
|
||||
func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*models.Relay) error {
|
||||
// Check if the bot exists and belongs to the owner
|
||||
_, err := s.GetBotByID(botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
tx, err := s.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete existing relays
|
||||
_, err = tx.Exec("DELETE FROM relays WHERE bot_id = ?", botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete existing relays: %w", err)
|
||||
}
|
||||
|
||||
// Insert new relays
|
||||
for _, relay := range relays {
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO relays (bot_id, url, read, write) VALUES (?, ?, ?, ?)",
|
||||
botID, relay.URL, relay.Read, relay.Write,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert relay: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishBotProfile publishes a bot's profile to Nostr
|
||||
func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
|
||||
// Get the bot
|
||||
bot, err := s.GetBotByID(botID, ownerPubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||
}
|
||||
|
||||
// Create and sign the metadata event
|
||||
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create metadata event: %w", err)
|
||||
}
|
||||
|
||||
// Set up relay connections
|
||||
for _, relay := range bot.Relays {
|
||||
if relay.Write {
|
||||
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
||||
s.logger.Warn("Failed to add relay",
|
||||
zap.String("url", relay.URL),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish the event
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
published, err := s.relayMgr.PublishEvent(ctx, event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish profile: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Published profile to relays",
|
||||
zap.Int64("botID", botID),
|
||||
zap.Strings("relays", published))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to load related data for a bot
|
||||
func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
|
||||
// Load post config
|
||||
var postConfig models.PostConfig
|
||||
err := s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", bot.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load post config: %w", err)
|
||||
}
|
||||
bot.PostConfig = &postConfig
|
||||
|
||||
// Load media config
|
||||
var mediaConfig models.MediaConfig
|
||||
err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", bot.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load media config: %w", err)
|
||||
}
|
||||
bot.MediaConfig = &mediaConfig
|
||||
|
||||
// Load relays
|
||||
var relays []*models.Relay
|
||||
err = s.db.Select(&relays, "SELECT * FROM relays WHERE bot_id = ?", bot.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load relays: %w", err)
|
||||
}
|
||||
bot.Relays = relays
|
||||
|
||||
return nil
|
||||
}
|
585
internal/api/routes.go
Normal file
585
internal/api/routes.go
Normal file
@ -0,0 +1,585 @@
|
||||
// internal/api/routes.go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// API represents the HTTP API for managing bots
|
||||
type API struct {
|
||||
router *gin.Engine
|
||||
logger *zap.Logger
|
||||
botService *BotService
|
||||
authService *auth.Service
|
||||
scheduler *scheduler.Scheduler
|
||||
}
|
||||
|
||||
// NewAPI creates a new API instance
|
||||
func NewAPI(
|
||||
logger *zap.Logger,
|
||||
botService *BotService,
|
||||
authService *auth.Service,
|
||||
scheduler *scheduler.Scheduler,
|
||||
) *API {
|
||||
router := gin.Default()
|
||||
|
||||
api := &API{
|
||||
router: router,
|
||||
logger: logger,
|
||||
botService: botService,
|
||||
authService: authService,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
|
||||
// Set up routes
|
||||
api.setupRoutes()
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
// SetupRoutes configures the API routes
|
||||
func (a *API) setupRoutes() {
|
||||
// Public routes
|
||||
a.router.GET("/health", a.healthCheck)
|
||||
|
||||
// API routes
|
||||
apiGroup := a.router.Group("/api")
|
||||
|
||||
// Authentication
|
||||
apiGroup.POST("/auth/login", a.login)
|
||||
apiGroup.GET("/auth/verify", a.requireAuth, a.verifyAuth)
|
||||
|
||||
// Bot management
|
||||
botGroup := apiGroup.Group("/bots")
|
||||
botGroup.Use(a.requireAuth)
|
||||
{
|
||||
botGroup.GET("", a.listBots)
|
||||
botGroup.POST("", a.createBot)
|
||||
botGroup.GET("/:id", a.getBot)
|
||||
botGroup.PUT("/:id", a.updateBot)
|
||||
botGroup.DELETE("/:id", a.deleteBot)
|
||||
|
||||
// Bot configuration
|
||||
botGroup.GET("/:id/config", a.getBotConfig)
|
||||
botGroup.PUT("/:id/config", a.updateBotConfig)
|
||||
|
||||
// Relay management
|
||||
botGroup.GET("/:id/relays", a.getBotRelays)
|
||||
botGroup.PUT("/:id/relays", a.updateBotRelays)
|
||||
|
||||
// Actions
|
||||
botGroup.POST("/:id/profile/publish", a.publishBotProfile)
|
||||
botGroup.POST("/:id/run", a.runBotNow)
|
||||
botGroup.POST("/:id/enable", a.enableBot)
|
||||
botGroup.POST("/:id/disable", a.disableBot)
|
||||
}
|
||||
|
||||
// Content management
|
||||
contentGroup := apiGroup.Group("/content")
|
||||
contentGroup.Use(a.requireAuth)
|
||||
{
|
||||
contentGroup.GET("/:botId", a.listBotContent)
|
||||
contentGroup.POST("/:botId/upload", a.uploadContent)
|
||||
contentGroup.DELETE("/:botId/:filename", a.deleteContent)
|
||||
}
|
||||
|
||||
// Stats
|
||||
statsGroup := apiGroup.Group("/stats")
|
||||
statsGroup.Use(a.requireAuth)
|
||||
{
|
||||
statsGroup.GET("", a.getStats)
|
||||
statsGroup.GET("/:botId", a.getBotStats)
|
||||
}
|
||||
|
||||
// Serve the web UI
|
||||
a.router.StaticFile("/", "./web/index.html")
|
||||
a.router.Static("/assets", "./web/assets")
|
||||
|
||||
// Handle 404s for SPA
|
||||
a.router.NoRoute(func(c *gin.Context) {
|
||||
c.File("./web/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the API server
|
||||
func (a *API) Run(addr string) error {
|
||||
return a.router.Run(addr)
|
||||
}
|
||||
|
||||
// Middleware for requiring authentication
|
||||
func (a *API) requireAuth(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the auth token
|
||||
pubkey, err := a.authService.VerifyToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Invalid authentication token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store the pubkey in the context
|
||||
c.Set("pubkey", pubkey)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// API Handlers
|
||||
|
||||
// healthCheck responds with a simple health check
|
||||
func (a *API) healthCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// login handles user login with NIP-07 signature
|
||||
func (a *API) login(c *gin.Context) {
|
||||
var req struct {
|
||||
Pubkey string `json:"pubkey" binding:"required"`
|
||||
Signature string `json:"signature" binding:"required"`
|
||||
Event string `json:"event" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
token, err := a.authService.Login(req.Pubkey, req.Signature, req.Event)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// verifyAuth verifies the current auth token
|
||||
func (a *API) verifyAuth(c *gin.Context) {
|
||||
pubkey, exists := c.Get("pubkey")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pubkey": pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
// listBots lists all bots owned by the user
|
||||
func (a *API) listBots(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
|
||||
bots, err := a.botService.ListUserBots(pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list bots"})
|
||||
a.logger.Error("Failed to list bots", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, bots)
|
||||
}
|
||||
|
||||
// createBot creates a new bot
|
||||
func (a *API) createBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
|
||||
var bot models.Bot
|
||||
if err := c.ShouldBindJSON(&bot); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set the owner
|
||||
bot.OwnerPubkey = pubkey
|
||||
|
||||
// Create the bot
|
||||
createdBot, err := a.botService.CreateBot(&bot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot"})
|
||||
a.logger.Error("Failed to create bot", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, createdBot)
|
||||
}
|
||||
|
||||
// getBot gets a specific bot by ID
|
||||
func (a *API) getBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the bot
|
||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, bot)
|
||||
}
|
||||
|
||||
// updateBot updates a bot
|
||||
func (a *API) updateBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var bot models.Bot
|
||||
if err := c.ShouldBindJSON(&bot); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set the ID and owner
|
||||
bot.ID = botID
|
||||
bot.OwnerPubkey = pubkey
|
||||
|
||||
// Update the bot
|
||||
updatedBot, err := a.botService.UpdateBot(&bot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"})
|
||||
a.logger.Error("Failed to update bot", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updatedBot)
|
||||
}
|
||||
|
||||
// deleteBot deletes a bot
|
||||
func (a *API) deleteBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the bot
|
||||
err = a.botService.DeleteBot(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bot"})
|
||||
a.logger.Error("Failed to delete bot", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getBotConfig gets a bot's configuration
|
||||
func (a *API) getBotConfig(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the bot with config
|
||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"post_config": bot.PostConfig,
|
||||
"media_config": bot.MediaConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// updateBotConfig updates a bot's configuration
|
||||
func (a *API) updateBotConfig(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var config struct {
|
||||
PostConfig *models.PostConfig `json:"post_config"`
|
||||
MediaConfig *models.MediaConfig `json:"media_config"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set the bot ID
|
||||
if config.PostConfig != nil {
|
||||
config.PostConfig.BotID = botID
|
||||
}
|
||||
if config.MediaConfig != nil {
|
||||
config.MediaConfig.BotID = botID
|
||||
}
|
||||
|
||||
// Update the configs
|
||||
err = a.botService.UpdateBotConfig(botID, pubkey, config.PostConfig, config.MediaConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"})
|
||||
a.logger.Error("Failed to update bot config", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the updated bot
|
||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the scheduler if needed
|
||||
if config.PostConfig != nil && config.PostConfig.Enabled {
|
||||
err = a.scheduler.UpdateBotSchedule(bot)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to update bot schedule",
|
||||
zap.Int64("botID", botID),
|
||||
zap.Error(err))
|
||||
}
|
||||
} else if config.PostConfig != nil && !config.PostConfig.Enabled {
|
||||
a.scheduler.UnscheduleBot(botID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"post_config": bot.PostConfig,
|
||||
"media_config": bot.MediaConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// getBotRelays gets a bot's relay configuration
|
||||
func (a *API) getBotRelays(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the relays
|
||||
relays, err := a.botService.GetBotRelays(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get relays"})
|
||||
a.logger.Error("Failed to get bot relays", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, relays)
|
||||
}
|
||||
|
||||
// updateBotRelays updates a bot's relay configuration
|
||||
func (a *API) updateBotRelays(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var relays []*models.Relay
|
||||
if err := c.ShouldBindJSON(&relays); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set the bot ID for each relay
|
||||
for _, relay := range relays {
|
||||
relay.BotID = botID
|
||||
}
|
||||
|
||||
// Update the relays
|
||||
err = a.botService.UpdateBotRelays(botID, pubkey, relays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update relays"})
|
||||
a.logger.Error("Failed to update bot relays", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the updated relays
|
||||
updatedRelays, err := a.botService.GetBotRelays(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated relays"})
|
||||
a.logger.Error("Failed to get updated bot relays", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updatedRelays)
|
||||
}
|
||||
|
||||
// publishBotProfile publishes a bot's profile to Nostr
|
||||
func (a *API) publishBotProfile(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Publish the profile
|
||||
err = a.botService.PublishBotProfile(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"})
|
||||
a.logger.Error("Failed to publish bot profile", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Profile published successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// runBotNow runs a bot immediately
|
||||
func (a *API) runBotNow(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the bot belongs to the user
|
||||
_, err = a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Run the bot
|
||||
err = a.scheduler.RunNow(botID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run bot"})
|
||||
a.logger.Error("Failed to run bot now", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bot is running",
|
||||
})
|
||||
}
|
||||
|
||||
// enableBot enables a bot
|
||||
func (a *API) enableBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the bot
|
||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Enable the bot
|
||||
bot.PostConfig.Enabled = true
|
||||
err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable bot"})
|
||||
a.logger.Error("Failed to enable bot", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the bot
|
||||
err = a.scheduler.ScheduleBot(bot)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to schedule bot",
|
||||
zap.Int64("botID", botID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bot enabled",
|
||||
})
|
||||
}
|
||||
|
||||
// disableBot disables a bot
|
||||
func (a *API) disableBot(c *gin.Context) {
|
||||
pubkey := c.GetString("pubkey")
|
||||
botID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the bot
|
||||
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Disable the bot
|
||||
bot.PostConfig.Enabled = false
|
||||
err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable bot"})
|
||||
a.logger.Error("Failed to disable bot", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Unschedule the bot
|
||||
a.scheduler.UnscheduleBot(botID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Bot disabled",
|
||||
})
|
||||
}
|
||||
|
||||
// listBotContent lists the content files for a bot
|
||||
func (a *API) listBotContent(c *gin.Context) {
|
||||
// This will be implemented when we add content management
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
// uploadContent uploads content for a bot
|
||||
func (a *API) uploadContent(c *gin.Context) {
|
||||
// This will be implemented when we add content management
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
// deleteContent deletes content for a bot
|
||||
func (a *API) deleteContent(c *gin.Context) {
|
||||
// This will be implemented when we add content management
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
// getStats gets overall statistics
|
||||
func (a *API) getStats(c *gin.Context) {
|
||||
// This will be implemented when we add statistics
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||
}
|
||||
|
||||
// getBotStats gets statistics for a specific bot
|
||||
func (a *API) getBotStats(c *gin.Context) {
|
||||
// This will be implemented when we add statistics
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||
}
|
188
internal/auth/auth.go
Normal file
188
internal/auth/auth.go
Normal file
@ -0,0 +1,188 @@
|
||||
// internal/auth/auth.go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidSignature is returned when a signature is invalid
|
||||
ErrInvalidSignature = errors.New("invalid signature")
|
||||
|
||||
// ErrTokenExpired is returned when a token has expired
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
|
||||
// ErrInvalidToken is returned when a token is invalid
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
)
|
||||
|
||||
// Token represents an authentication token
|
||||
type Token struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// Service provides authentication functionality
|
||||
type Service struct {
|
||||
db *db.DB
|
||||
logger *zap.Logger
|
||||
secretKey []byte
|
||||
tokenDuration time.Duration
|
||||
}
|
||||
|
||||
// NewService creates a new authentication service
|
||||
func NewService(db *db.DB, logger *zap.Logger, secretKey string, tokenDuration time.Duration) *Service {
|
||||
// If no secret key is provided, generate a secure random one
|
||||
decodedKey := []byte(secretKey)
|
||||
if secretKey == "" {
|
||||
// Generate a secure random key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
logger.Fatal("Failed to generate random key for auth service", zap.Error(err))
|
||||
}
|
||||
decodedKey = key
|
||||
}
|
||||
|
||||
return &Service{
|
||||
db: db,
|
||||
logger: logger,
|
||||
secretKey: decodedKey,
|
||||
tokenDuration: tokenDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Login handles user login with a Nostr signature
|
||||
func (s *Service) Login(pubkey, signature, eventJSON string) (string, error) {
|
||||
// Parse the event
|
||||
var event nostr.Event
|
||||
if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
|
||||
return "", fmt.Errorf("failed to parse event: %w", err)
|
||||
}
|
||||
|
||||
// Verify the event
|
||||
if event.PubKey != pubkey {
|
||||
return "", errors.New("pubkey mismatch in event")
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
ok, err := event.CheckSignature()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check signature: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrInvalidSignature
|
||||
}
|
||||
|
||||
// Check if the event was created recently
|
||||
now := time.Now()
|
||||
eventTime := time.Unix(int64(event.CreatedAt), 0)
|
||||
if now.Sub(eventTime) > 5*time.Minute || eventTime.After(now.Add(5*time.Minute)) {
|
||||
return "", errors.New("event timestamp is too far from current time")
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
token, err := s.createToken(pubkey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyToken validates an authentication token
|
||||
func (s *Service) VerifyToken(tokenStr string) (string, error) {
|
||||
// Remove the "Bearer " prefix if present
|
||||
tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")
|
||||
|
||||
// Decode the token
|
||||
tokenData, err := base64.StdEncoding.DecodeString(tokenStr)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
|
||||
// Parse the token
|
||||
var token Token
|
||||
if err := json.Unmarshal(tokenData, &token); err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
|
||||
// Check if the token has expired
|
||||
if time.Now().After(token.ExpiresAt) {
|
||||
return "", ErrTokenExpired
|
||||
}
|
||||
|
||||
return token.Pubkey, nil
|
||||
}
|
||||
|
||||
// CreateNIP07Challenge creates a challenge for NIP-07 authentication
|
||||
func (s *Service) CreateNIP07Challenge(pubkey string) (string, error) {
|
||||
// Generate a nonce
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonceStr := hex.EncodeToString(nonce)
|
||||
|
||||
// Create the challenge event
|
||||
event := nostr.Event{
|
||||
PubKey: pubkey,
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Kind: 22242, // Ephemeral event for authentication
|
||||
Tags: []nostr.Tag{{"challenge", nonceStr}},
|
||||
Content: "Please sign this event to authenticate with Nostr Poster",
|
||||
}
|
||||
|
||||
// Set the ID
|
||||
event.ID = event.GetID()
|
||||
|
||||
// Convert to JSON
|
||||
eventJSON, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize event: %w", err)
|
||||
}
|
||||
|
||||
return string(eventJSON), nil
|
||||
}
|
||||
|
||||
// createToken creates an authentication token
|
||||
func (s *Service) createToken(pubkey string) (string, error) {
|
||||
// Generate a nonce
|
||||
nonce := make([]byte, 8)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonceStr := hex.EncodeToString(nonce)
|
||||
|
||||
// Create the token
|
||||
now := time.Now()
|
||||
token := Token{
|
||||
Pubkey: pubkey,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(s.tokenDuration),
|
||||
Nonce: nonceStr,
|
||||
}
|
||||
|
||||
// Serialize the token
|
||||
tokenData, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize token: %w", err)
|
||||
}
|
||||
|
||||
// Encode the token
|
||||
tokenStr := base64.StdEncoding.EncodeToString(tokenData)
|
||||
|
||||
return tokenStr, nil
|
||||
}
|
171
internal/config/config.go
Normal file
171
internal/config/config.go
Normal file
@ -0,0 +1,171 @@
|
||||
// internal/config/config.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the application
|
||||
type Config struct {
|
||||
// General configuration
|
||||
AppName string `mapstructure:"app_name"`
|
||||
ServerPort int `mapstructure:"server_port"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
|
||||
// Bot configuration
|
||||
Bot struct {
|
||||
KeysFile string `mapstructure:"keys_file"`
|
||||
ContentDir string `mapstructure:"content_dir"`
|
||||
ArchiveDir string `mapstructure:"archive_dir"`
|
||||
DefaultInterval int `mapstructure:"default_interval"` // in minutes
|
||||
} `mapstructure:"bot"`
|
||||
|
||||
// Database configuration
|
||||
DB struct {
|
||||
Path string `mapstructure:"path"`
|
||||
} `mapstructure:"db"`
|
||||
|
||||
// Media services configuration
|
||||
Media struct {
|
||||
DefaultService string `mapstructure:"default_service"` // "nip94" or "blossom"
|
||||
|
||||
// NIP-94 configuration
|
||||
NIP94 struct {
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
RequireAuth bool `mapstructure:"require_auth"`
|
||||
} `mapstructure:"nip94"`
|
||||
|
||||
// Blossom configuration
|
||||
Blossom struct {
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
} `mapstructure:"blossom"`
|
||||
} `mapstructure:"media"`
|
||||
|
||||
// Default relays
|
||||
Relays []struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Read bool `mapstructure:"read"`
|
||||
Write bool `mapstructure:"write"`
|
||||
} `mapstructure:"relays"`
|
||||
}
|
||||
|
||||
// LoadConfig loads the configuration from file or environment variables
|
||||
func LoadConfig(configPath string) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set defaults
|
||||
v.SetDefault("app_name", "Nostr Poster")
|
||||
v.SetDefault("server_port", 8080)
|
||||
v.SetDefault("log_level", "info")
|
||||
|
||||
v.SetDefault("bot.keys_file", "keys.json")
|
||||
v.SetDefault("bot.content_dir", "./content")
|
||||
v.SetDefault("bot.archive_dir", "./archive")
|
||||
v.SetDefault("bot.default_interval", 60) // 1 hour
|
||||
|
||||
v.SetDefault("db.path", "./nostr-poster.db")
|
||||
|
||||
v.SetDefault("media.default_service", "nip94")
|
||||
v.SetDefault("media.nip94.require_auth", true)
|
||||
|
||||
// Default relays
|
||||
v.SetDefault("relays", []map[string]interface{}{
|
||||
{"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},
|
||||
})
|
||||
|
||||
// Setup config file search
|
||||
if configPath != "" {
|
||||
// Use config file from the flag
|
||||
v.SetConfigFile(configPath)
|
||||
} else {
|
||||
// Try to find config in default locations
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./config")
|
||||
v.AddConfigPath("$HOME/.nostr-poster")
|
||||
v.SetConfigName("config")
|
||||
}
|
||||
|
||||
// Read the config file
|
||||
v.SetConfigType("yaml")
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
// Config file not found, we'll create a default one
|
||||
fmt.Println("Config file not found, creating default config")
|
||||
defaultConfig := &Config{}
|
||||
v.Unmarshal(defaultConfig)
|
||||
|
||||
// Ensure directories exist
|
||||
os.MkdirAll(filepath.Dir(v.GetString("db.path")), 0755)
|
||||
os.MkdirAll(v.GetString("bot.content_dir"), 0755)
|
||||
os.MkdirAll(v.GetString("bot.archive_dir"), 0755)
|
||||
|
||||
// Create default config file
|
||||
if err := v.SafeWriteConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to write default config: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Config file was found but another error was produced
|
||||
return nil, fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variables can override config values
|
||||
v.SetEnvPrefix("NOSTR_POSTER")
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Parse config into struct
|
||||
config := &Config{}
|
||||
if err := v.Unmarshal(config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Save writes the current configuration to disk
|
||||
func (c *Config) Save(configPath string) error {
|
||||
v := viper.New()
|
||||
|
||||
// Convert config struct to map
|
||||
err := v.MergeConfigMap(map[string]interface{}{
|
||||
"app_name": c.AppName,
|
||||
"server_port": c.ServerPort,
|
||||
"log_level": c.LogLevel,
|
||||
"bot": map[string]interface{}{
|
||||
"keys_file": c.Bot.KeysFile,
|
||||
"content_dir": c.Bot.ContentDir,
|
||||
"archive_dir": c.Bot.ArchiveDir,
|
||||
"default_interval": c.Bot.DefaultInterval,
|
||||
},
|
||||
"db": map[string]interface{}{
|
||||
"path": c.DB.Path,
|
||||
},
|
||||
"media": map[string]interface{}{
|
||||
"default_service": c.Media.DefaultService,
|
||||
"nip94": map[string]interface{}{
|
||||
"server_url": c.Media.NIP94.ServerURL,
|
||||
"require_auth": c.Media.NIP94.RequireAuth,
|
||||
},
|
||||
"blossom": map[string]interface{}{
|
||||
"server_url": c.Media.Blossom.ServerURL,
|
||||
},
|
||||
},
|
||||
"relays": c.Relays,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
// Set the config path
|
||||
v.SetConfigFile(configPath)
|
||||
|
||||
// Write the config file
|
||||
return v.WriteConfig()
|
||||
}
|
307
internal/crypto/keys.go
Normal file
307
internal/crypto/keys.go
Normal file
@ -0,0 +1,307 @@
|
||||
// internal/crypto/keys.go
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrKeyNotFound is returned when a key for the specified pubkey cannot be found
|
||||
ErrKeyNotFound = errors.New("key not found")
|
||||
|
||||
// ErrInvalidKey is returned when an invalid key is provided
|
||||
ErrInvalidKey = errors.New("invalid key")
|
||||
|
||||
// ErrDecryptionFailed is returned when decryption of a private key fails
|
||||
ErrDecryptionFailed = errors.New("decryption failed")
|
||||
)
|
||||
|
||||
// KeyStore manages encryption and storage of Nostr keys
|
||||
type KeyStore struct {
|
||||
filePath string
|
||||
keys map[string]string // pubkey -> encrypted privkey
|
||||
password []byte
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewKeyStore creates a new KeyStore
|
||||
func NewKeyStore(filePath string, password string) (*KeyStore, error) {
|
||||
ks := &KeyStore{
|
||||
filePath: filePath,
|
||||
keys: make(map[string]string),
|
||||
password: []byte(password),
|
||||
}
|
||||
|
||||
// Try to load existing keys
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
if err := ks.loadKeys(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ks, nil
|
||||
}
|
||||
|
||||
// loadKeys loads encrypted keys from the key file
|
||||
func (ks *KeyStore) loadKeys() error {
|
||||
ks.mu.Lock()
|
||||
defer ks.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(ks.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, &ks.keys)
|
||||
}
|
||||
|
||||
// saveKeys saves the encrypted keys to the key file
|
||||
func (ks *KeyStore) saveKeys() error {
|
||||
ks.mu.RLock()
|
||||
defer ks.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(ks.keys, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ks.filePath, data, 0600)
|
||||
}
|
||||
|
||||
// GenerateKey generates a new Nostr keypair
|
||||
func (ks *KeyStore) GenerateKey() (pubkey, privkey string, err error) {
|
||||
// Generate a new keypair
|
||||
sk := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, sk); err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Convert to hex strings
|
||||
privkey = hex.EncodeToString(sk)
|
||||
pub, err := nostr.GetPublicKey(privkey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to derive public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt and store the private key
|
||||
if err := ks.AddKey(pub, privkey); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return pub, privkey, nil
|
||||
}
|
||||
|
||||
// AddKey imports an existing private key
|
||||
func (ks *KeyStore) AddKey(pubkey, privkey string) error {
|
||||
// Validate the key pair
|
||||
derivedPub, err := nostr.GetPublicKey(privkey)
|
||||
if err != nil {
|
||||
return ErrInvalidKey
|
||||
}
|
||||
|
||||
if derivedPub != pubkey {
|
||||
return errors.New("public key does not match private key")
|
||||
}
|
||||
|
||||
// Encrypt the private key
|
||||
encPrivkey, err := ks.encryptKey(privkey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store the encrypted key
|
||||
ks.mu.Lock()
|
||||
ks.keys[pubkey] = encPrivkey
|
||||
ks.mu.Unlock()
|
||||
|
||||
// Save to disk
|
||||
return ks.saveKeys()
|
||||
}
|
||||
|
||||
// ImportKey imports a key from nsec or hex format
|
||||
func (ks *KeyStore) ImportKey(keyStr string) (string, error) {
|
||||
var privkeyHex string
|
||||
var err error
|
||||
|
||||
// Check if it's an nsec key
|
||||
if len(keyStr) > 4 && keyStr[:4] == "nsec" {
|
||||
privkeyHex, err = decodePrivateKey(keyStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid nsec key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Assume it's a hex key
|
||||
if len(keyStr) != 64 {
|
||||
return "", ErrInvalidKey
|
||||
}
|
||||
privkeyHex = keyStr
|
||||
}
|
||||
|
||||
// Derive the public key
|
||||
pubkey, err := nostr.GetPublicKey(privkeyHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
|
||||
// Add the key to the store
|
||||
if err := ks.AddKey(pubkey, privkeyHex); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
// GetPrivateKey retrieves and decrypts a private key for the given pubkey
|
||||
func (ks *KeyStore) GetPrivateKey(pubkey string) (string, error) {
|
||||
ks.mu.RLock()
|
||||
encryptedKey, exists := ks.keys[pubkey]
|
||||
ks.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
|
||||
return ks.decryptKey(encryptedKey)
|
||||
}
|
||||
|
||||
// RemoveKey removes a key from the store
|
||||
func (ks *KeyStore) RemoveKey(pubkey string) error {
|
||||
ks.mu.Lock()
|
||||
delete(ks.keys, pubkey)
|
||||
ks.mu.Unlock()
|
||||
|
||||
return ks.saveKeys()
|
||||
}
|
||||
|
||||
// ListKeys returns a list of all stored public keys
|
||||
func (ks *KeyStore) ListKeys() []string {
|
||||
ks.mu.RLock()
|
||||
defer ks.mu.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(ks.keys))
|
||||
for k := range ks.keys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// encryptKey encrypts a private key using the store's password
|
||||
func (ks *KeyStore) encryptKey(privkey string) (string, error) {
|
||||
// Generate a random nonce
|
||||
var nonce [24]byte
|
||||
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Create a key from the password
|
||||
var key [32]byte
|
||||
copy(key[:], ks.password)
|
||||
|
||||
// Encrypt the private key
|
||||
privkeyBytes := []byte(privkey)
|
||||
encrypted := secretbox.Seal(nonce[:], privkeyBytes, &nonce, &key)
|
||||
|
||||
return hex.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// decryptKey decrypts an encrypted private key
|
||||
func (ks *KeyStore) decryptKey(encryptedKey string) (string, error) {
|
||||
encryptedData, err := hex.DecodeString(encryptedKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid encrypted key format: %w", err)
|
||||
}
|
||||
|
||||
// The first 24 bytes are the nonce
|
||||
if len(encryptedData) < 24 {
|
||||
return "", ErrDecryptionFailed
|
||||
}
|
||||
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], encryptedData[:24])
|
||||
|
||||
// Create a key from the password
|
||||
var key [32]byte
|
||||
copy(key[:], ks.password)
|
||||
|
||||
// Decrypt the private key
|
||||
decryptedData, ok := secretbox.Open(nil, encryptedData[24:], &nonce, &key)
|
||||
if !ok {
|
||||
return "", ErrDecryptionFailed
|
||||
}
|
||||
|
||||
return string(decryptedData), nil
|
||||
}
|
||||
|
||||
// ChangePassword changes the password used for key encryption
|
||||
func (ks *KeyStore) ChangePassword(newPassword string) error {
|
||||
ks.mu.Lock()
|
||||
defer ks.mu.Unlock()
|
||||
|
||||
// Create a copy of current keys
|
||||
oldKeys := make(map[string]string)
|
||||
for k, v := range ks.keys {
|
||||
oldKeys[k] = v
|
||||
}
|
||||
|
||||
// Change the password
|
||||
oldPassword := ks.password
|
||||
ks.password = []byte(newPassword)
|
||||
|
||||
// Re-encrypt all keys with the new password
|
||||
for pubkey, encryptedKey := range oldKeys {
|
||||
// Get the plaintext private key using the old password
|
||||
ks.password = oldPassword
|
||||
privkey, err := ks.decryptKey(encryptedKey)
|
||||
if err != nil {
|
||||
// Restore the old password and return
|
||||
ks.password = oldPassword
|
||||
return fmt.Errorf("failed to decrypt key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt it with the new password
|
||||
ks.password = []byte(newPassword)
|
||||
newEncryptedKey, err := ks.encryptKey(privkey)
|
||||
if err != nil {
|
||||
// Restore the old password and return
|
||||
ks.password = oldPassword
|
||||
return fmt.Errorf("failed to re-encrypt key: %w", err)
|
||||
}
|
||||
|
||||
// Update the key in the store
|
||||
ks.keys[pubkey] = newEncryptedKey
|
||||
}
|
||||
|
||||
// Save the re-encrypted keys
|
||||
return ks.saveKeys()
|
||||
}
|
||||
|
||||
// decodePrivateKey decodes an nsec private key
|
||||
func decodePrivateKey(nsecKey string) (string, error) {
|
||||
if len(nsecKey) < 4 || nsecKey[:4] != "nsec" {
|
||||
return "", fmt.Errorf("invalid nsec key")
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(nsecKey[4:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
|
||||
// Remove the version byte and checksum
|
||||
if len(data) < 2 {
|
||||
return "", fmt.Errorf("invalid nsec data length")
|
||||
}
|
||||
|
||||
privkeyBytes := data[1 : len(data)-4]
|
||||
return hex.EncodeToString(privkeyBytes), nil
|
||||
}
|
128
internal/db/db.go
Normal file
128
internal/db/db.go
Normal file
@ -0,0 +1,128 @@
|
||||
// internal/db/db.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB holds the database connection
|
||||
type DB struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
// New creates a new DB instance
|
||||
func New(dbPath string) (*DB, error) {
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := ensureDir(dir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
db, err := sqlx.Connect("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Set pragmas for better performance
|
||||
db.MustExec("PRAGMA journal_mode=WAL;")
|
||||
db.MustExec("PRAGMA foreign_keys=ON;")
|
||||
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// Initialize creates the database schema if it doesn't exist
|
||||
func (db *DB) Initialize() error {
|
||||
// Create bots table
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS bots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pubkey TEXT NOT NULL UNIQUE,
|
||||
encrypted_privkey TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
bio TEXT,
|
||||
nip05 TEXT,
|
||||
zap_address TEXT,
|
||||
profile_picture TEXT,
|
||||
banner TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
owner_pubkey TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bots table: %w", err)
|
||||
}
|
||||
|
||||
// Create post_config table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS post_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id INTEGER NOT NULL,
|
||||
hashtags TEXT,
|
||||
interval_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
post_template TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create post_config table: %w", err)
|
||||
}
|
||||
|
||||
// Create media_config table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS media_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id INTEGER NOT NULL,
|
||||
primary_service TEXT NOT NULL,
|
||||
fallback_service TEXT,
|
||||
nip94_server_url TEXT,
|
||||
blossom_server_url TEXT,
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create media_config table: %w", err)
|
||||
}
|
||||
|
||||
// Create relays table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS relays (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL DEFAULT 1,
|
||||
write BOOLEAN NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE,
|
||||
UNIQUE (bot_id, url)
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create relays table: %w", err)
|
||||
}
|
||||
|
||||
// Create posts table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id INTEGER NOT NULL,
|
||||
content_filename TEXT,
|
||||
media_url TEXT,
|
||||
event_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
error TEXT,
|
||||
FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create posts table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDir makes sure the directory exists
|
||||
func ensureDir(dir string) error {
|
||||
return nil // This will be implemented in a file utility package
|
||||
}
|
181
internal/media/prepare/prepare.go
Normal file
181
internal/media/prepare/prepare.go
Normal file
@ -0,0 +1,181 @@
|
||||
// internal/media/prepare/prepare.go
|
||||
package prepare
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ImageDimensions represents the dimensions of an image
|
||||
type ImageDimensions struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// Manager handles media preparation and optimization
|
||||
type Manager struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a new preparation manager
|
||||
func NewManager(logger *zap.Logger) *Manager {
|
||||
if logger == nil {
|
||||
// Create a default logger if none is provided
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
// If we can't create a logger, use a no-op logger
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMediaDimensions gets the dimensions of an image or video file
|
||||
func (m *Manager) GetMediaDimensions(filePath string) (*ImageDimensions, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get the file extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
// Decode the image based on the file extension
|
||||
var img image.Image
|
||||
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
img, err = jpeg.Decode(file)
|
||||
case ".png":
|
||||
img, err = png.Decode(file)
|
||||
case ".gif":
|
||||
img, err = gif.Decode(file)
|
||||
case ".webp":
|
||||
img, err = webp.Decode(file)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported image format: %s", ext)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Get the dimensions
|
||||
bounds := img.Bounds()
|
||||
return &ImageDimensions{
|
||||
Width: bounds.Max.X - bounds.Min.X,
|
||||
Height: bounds.Max.Y - bounds.Min.Y,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResizeImage resizes an image to the specified dimensions
|
||||
// Returns the path to the resized image
|
||||
func (m *Manager) ResizeImage(filePath string, maxWidth, maxHeight int) (string, error) {
|
||||
// For now, just return the original image
|
||||
// In a future update, we can add actual resizing functionality
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// OptimizeImage optimizes an image for web usage
|
||||
// Returns the path to the optimized image
|
||||
func (m *Manager) OptimizeImage(filePath string) (string, error) {
|
||||
// For now, just return the original image
|
||||
// In a future update, we can add optimization functionality
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// GetMediaType gets the media type of a file
|
||||
func (m *Manager) GetMediaType(filePath string) (string, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the first 512 bytes to detect the content type
|
||||
buffer := make([]byte, 512)
|
||||
_, err = file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Detect the content type
|
||||
contentType := detectContentType(buffer)
|
||||
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// ExtractMetadata extracts metadata from a media file
|
||||
func (m *Manager) ExtractMetadata(filePath string) (map[string]interface{}, error) {
|
||||
// Get the media type
|
||||
mediaType, err := m.GetMediaType(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get media type: %w", err)
|
||||
}
|
||||
|
||||
// Create the metadata map
|
||||
metadata := map[string]interface{}{
|
||||
"media_type": mediaType,
|
||||
}
|
||||
|
||||
// If it's an image, get the dimensions
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
dims, err := m.GetMediaDimensions(filePath)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to get image dimensions",
|
||||
zap.String("filePath", filePath),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
metadata["width"] = dims.Width
|
||||
metadata["height"] = dims.Height
|
||||
metadata["dimensions"] = fmt.Sprintf("%dx%d", dims.Width, dims.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file size
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to get file size",
|
||||
zap.String("filePath", filePath),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
metadata["size"] = fileInfo.Size()
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// detectContentType detects the content type of a file
|
||||
func detectContentType(buffer []byte) string {
|
||||
// Try to detect the content type
|
||||
contentType := http.DetectContentType(buffer)
|
||||
|
||||
// Some additional checks for specific formats
|
||||
if contentType == "application/octet-stream" {
|
||||
// Check for WebP signature
|
||||
if bytes.HasPrefix(buffer, []byte("RIFF")) && bytes.Contains(buffer[8:12], []byte("WEBP")) {
|
||||
return "image/webp"
|
||||
}
|
||||
}
|
||||
|
||||
return contentType
|
||||
}
|
304
internal/media/upload/blossom/upload.go
Normal file
304
internal/media/upload/blossom/upload.go
Normal file
@ -0,0 +1,304 @@
|
||||
// internal/media/upload/blossom/upload.go
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// BlobDescriptor represents metadata about a blob stored with Blossom
|
||||
type BlobDescriptor struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Alt string `json:"alt,omitempty"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// UploadResponse represents the response from a Blossom upload
|
||||
type UploadResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Blob BlobDescriptor `json:"blob"`
|
||||
}
|
||||
|
||||
// Uploader implements the media upload functionality for Blossom
|
||||
type Uploader struct {
|
||||
serverURL string
|
||||
logger *zap.Logger
|
||||
// Function to get a signed auth header (for Blossom authentication)
|
||||
getAuthHeader func(url, method string) (string, error)
|
||||
}
|
||||
|
||||
// NewUploader creates a new Blossom uploader
|
||||
func NewUploader(
|
||||
serverURL string,
|
||||
logger *zap.Logger,
|
||||
getAuthHeader func(url, method string) (string, error),
|
||||
) *Uploader {
|
||||
if logger == nil {
|
||||
// Create a default logger if none is provided
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
// If we can't create a logger, use a no-op logger
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
return &Uploader{
|
||||
serverURL: serverURL,
|
||||
logger: logger,
|
||||
getAuthHeader: getAuthHeader,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile uploads a file to a Blossom server
|
||||
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Calculate file hash
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", "", fmt.Errorf("failed to calculate file hash: %w", err)
|
||||
}
|
||||
|
||||
// Reset file pointer
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return "", "", fmt.Errorf("failed to reset file: %w", err)
|
||||
}
|
||||
|
||||
// Get hash as hex
|
||||
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Get content type
|
||||
contentType, err := utils.GetFileContentType(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to determine content type: %w", err)
|
||||
}
|
||||
|
||||
// Create a buffer for the multipart form
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
// Add the file
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return "", "", fmt.Errorf("failed to copy file to form: %w", err)
|
||||
}
|
||||
|
||||
// Add caption if provided
|
||||
if caption != "" {
|
||||
if err := writer.WriteField("caption", caption); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add caption: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add alt text if provided
|
||||
if altText != "" {
|
||||
if err := writer.WriteField("alt", altText); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add alt text: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add file size information
|
||||
if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add file size: %w", err)
|
||||
}
|
||||
|
||||
// Add content type
|
||||
if err := writer.WriteField("content_type", contentType); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add content type: %w", err)
|
||||
}
|
||||
|
||||
// Add file hash (for integrity verification)
|
||||
if err := writer.WriteField("hash", fileHash); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add file hash: %w", err)
|
||||
}
|
||||
|
||||
// Close the writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create the upload URL
|
||||
uploadURL := fmt.Sprintf("%s/upload", u.serverURL)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", uploadURL, &requestBody)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set content type
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Add authorization header if available
|
||||
if u.getAuthHeader != nil {
|
||||
authHeader, err := u.getAuthHeader(uploadURL, "PUT")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
// Send the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var uploadResp UploadResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Check for success
|
||||
if uploadResp.Status != "success" {
|
||||
return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message)
|
||||
}
|
||||
|
||||
// Get the media URL
|
||||
mediaURL := fmt.Sprintf("%s/%s", u.serverURL, uploadResp.Blob.SHA256)
|
||||
|
||||
// Verify the returned hash matches our calculated hash
|
||||
if uploadResp.Blob.SHA256 != fileHash {
|
||||
u.logger.Warn("Server returned different hash than calculated",
|
||||
zap.String("calculated", fileHash),
|
||||
zap.String("returned", uploadResp.Blob.SHA256))
|
||||
}
|
||||
|
||||
return mediaURL, uploadResp.Blob.SHA256, nil
|
||||
}
|
||||
|
||||
// DeleteFile deletes a file from the Blossom server
|
||||
func (u *Uploader) DeleteFile(fileHash string) error {
|
||||
// Create the delete URL
|
||||
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
// Add authorization header if available
|
||||
if u.getAuthHeader != nil {
|
||||
authHeader, err := u.getAuthHeader(deleteURL, "DELETE")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth header: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Send the request
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send delete request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBlossomAuthHeader creates a Blossom authentication header
|
||||
// BUD-01 requires a kind 24242 authorization event
|
||||
func CreateBlossomAuthHeader(url, method string, privkey string) (string, error) {
|
||||
// Create the event
|
||||
tags := []nostr.Tag{
|
||||
{"u", url},
|
||||
{"method", method},
|
||||
}
|
||||
|
||||
// Create the auth event
|
||||
authEvent := nostr.Event{
|
||||
Kind: 24242, // Blossom Authorization event
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: "", // Empty content for auth events
|
||||
}
|
||||
|
||||
// Get the public key
|
||||
pubkey, err := nostr.GetPublicKey(privkey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
authEvent.PubKey = pubkey
|
||||
|
||||
// Sign the event
|
||||
err = authEvent.Sign(privkey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign auth event: %w", err)
|
||||
}
|
||||
|
||||
// Serialize the event
|
||||
eventJSON, err := json.Marshal(authEvent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize auth event: %w", err)
|
||||
}
|
||||
|
||||
// Encode as base64
|
||||
encodedEvent := base64.StdEncoding.EncodeToString(eventJSON)
|
||||
|
||||
// Return the authorization header
|
||||
return "Nostr " + encodedEvent, nil
|
||||
}
|
473
internal/media/upload/nip94/upload.go
Normal file
473
internal/media/upload/nip94/upload.go
Normal file
@ -0,0 +1,473 @@
|
||||
// internal/media/upload/nip94/upload.go
|
||||
package nip94
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NIP96ServerConfig represents the configuration for a NIP-96 server
|
||||
type NIP96ServerConfig struct {
|
||||
APIURL string `json:"api_url"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
DelegatedToURL string `json:"delegated_to_url,omitempty"`
|
||||
SupportedContentTypes []string `json:"content_types,omitempty"`
|
||||
}
|
||||
|
||||
// NIP96UploadResponse represents the response from a NIP-96 server upload
|
||||
type NIP96UploadResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
ProcessingURL string `json:"processing_url,omitempty"`
|
||||
NIP94Event struct {
|
||||
Tags [][]string `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
} `json:"nip94_event"`
|
||||
}
|
||||
|
||||
// Uploader implements the media upload functionality for NIP-94/96
|
||||
type Uploader struct {
|
||||
serverURL string
|
||||
downloadURL string
|
||||
supportedTypes []string
|
||||
logger *zap.Logger
|
||||
// Function to get a signed auth header (for NIP-98)
|
||||
getAuthHeader func(url, method string, payload []byte) (string, error)
|
||||
}
|
||||
|
||||
// NewUploader creates a new NIP-94/96 uploader
|
||||
func NewUploader(
|
||||
serverURL string,
|
||||
downloadURL string,
|
||||
supportedTypes []string,
|
||||
logger *zap.Logger,
|
||||
getAuthHeader func(url, method string, payload []byte) (string, error),
|
||||
) *Uploader {
|
||||
if logger == nil {
|
||||
// Create a default logger if none is provided
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
// If we can't create a logger, use a no-op logger
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
return &Uploader{
|
||||
serverURL: serverURL,
|
||||
downloadURL: downloadURL,
|
||||
supportedTypes: supportedTypes,
|
||||
logger: logger,
|
||||
getAuthHeader: getAuthHeader,
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverServer discovers a NIP-96 server's configuration
|
||||
func DiscoverServer(serverURL string) (*NIP96ServerConfig, error) {
|
||||
// Make sure we have the base URL without path
|
||||
baseURL := serverURL
|
||||
|
||||
// Fetch the well-known JSON file
|
||||
resp, err := http.Get(baseURL + "/.well-known/nostr/nip96.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch server configuration: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("server returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var config NIP96ServerConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse server configuration: %w", err)
|
||||
}
|
||||
|
||||
// If the server delegates to another URL, follow the delegation
|
||||
if config.APIURL == "" && config.DownloadURL == "" {
|
||||
delegatedURL := config.DelegatedToURL
|
||||
if delegatedURL == "" {
|
||||
return nil, errors.New("server configuration missing both api_url and delegated_to_url")
|
||||
}
|
||||
|
||||
return DiscoverServer(delegatedURL)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// UploadFile uploads a file to a NIP-96 compatible server
|
||||
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Calculate file hash
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", "", fmt.Errorf("failed to calculate file hash: %w", err)
|
||||
}
|
||||
|
||||
// Reset file pointer
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return "", "", fmt.Errorf("failed to reset file: %w", err)
|
||||
}
|
||||
|
||||
// Get hash as hex
|
||||
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Get content type
|
||||
contentType, err := utils.GetFileContentType(filePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to determine content type: %w", err)
|
||||
}
|
||||
|
||||
// Create a buffer for the multipart form
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
// Add the file
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return "", "", fmt.Errorf("failed to copy file to form: %w", err)
|
||||
}
|
||||
|
||||
// Add caption if provided
|
||||
if caption != "" {
|
||||
if err := writer.WriteField("caption", caption); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add caption: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add alt text if provided
|
||||
if altText != "" {
|
||||
if err := writer.WriteField("alt", altText); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add alt text: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add content type
|
||||
if err := writer.WriteField("content_type", contentType); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add content type: %w", err)
|
||||
}
|
||||
|
||||
// Add file size
|
||||
if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add file size: %w", err)
|
||||
}
|
||||
|
||||
// Add file hash for integrity verification
|
||||
if err := writer.WriteField("hash", fileHash); err != nil {
|
||||
return "", "", fmt.Errorf("failed to add file hash: %w", err)
|
||||
}
|
||||
|
||||
// Close the writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("POST", u.serverURL, &requestBody)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set content type
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
// Get the request body for the NIP-98 auth
|
||||
bodyBytes := requestBody.Bytes()
|
||||
|
||||
// Add NIP-98 auth header if available
|
||||
if u.getAuthHeader != nil {
|
||||
// Calculate SHA-256 of the entire request body for more comprehensive authentication
|
||||
bodyHasher := sha256.New()
|
||||
bodyHasher.Write(bodyBytes)
|
||||
bodyHash := bodyHasher.Sum(nil)
|
||||
|
||||
// Use the body hash for authentication
|
||||
authHeader, err := u.getAuthHeader(u.serverURL, "POST", bodyHash)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
// Send the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var uploadResp NIP96UploadResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Check if we need to wait for processing
|
||||
if uploadResp.Status == "processing" && uploadResp.ProcessingURL != "" {
|
||||
// Wait for processing to complete
|
||||
return u.waitForProcessing(uploadResp.ProcessingURL)
|
||||
}
|
||||
|
||||
// Check for success
|
||||
if uploadResp.Status != "success" {
|
||||
return "", "", fmt.Errorf("upload failed: %s", uploadResp.Message)
|
||||
}
|
||||
|
||||
// Extract URL and hash from the NIP-94 event
|
||||
var mediaURL string
|
||||
var mediaHash string
|
||||
|
||||
for _, tag := range uploadResp.NIP94Event.Tags {
|
||||
if len(tag) >= 2 {
|
||||
if tag[0] == "url" {
|
||||
mediaURL = tag[1]
|
||||
} else if tag[0] == "ox" || tag[0] == "x" {
|
||||
mediaHash = tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mediaURL == "" {
|
||||
return "", "", errors.New("missing URL in response")
|
||||
}
|
||||
|
||||
// Verify the hash matches what we calculated
|
||||
if mediaHash != "" && mediaHash != fileHash {
|
||||
u.logger.Warn("Server returned different hash than calculated",
|
||||
zap.String("calculated", fileHash),
|
||||
zap.String("returned", mediaHash))
|
||||
}
|
||||
|
||||
return mediaURL, mediaHash, nil
|
||||
}
|
||||
|
||||
// waitForProcessing waits for a file processing to complete
|
||||
func (u *Uploader) waitForProcessing(processingURL string) (string, string, error) {
|
||||
// Create HTTP client
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Try several times
|
||||
maxAttempts := 10
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
// Wait before retry
|
||||
if attempt > 1 {
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
}
|
||||
|
||||
// Check processing status
|
||||
resp, err := client.Get(processingURL)
|
||||
if err != nil {
|
||||
u.logger.Warn("Failed to check processing status",
|
||||
zap.String("url", processingURL),
|
||||
zap.Error(err),
|
||||
zap.Int("attempt", attempt))
|
||||
continue
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
u.logger.Warn("Failed to read processing response",
|
||||
zap.Error(err),
|
||||
zap.Int("attempt", attempt))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if processing is complete
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
// Parse the complete response
|
||||
var uploadResp NIP96UploadResponse
|
||||
if err := json.Unmarshal(body, &uploadResp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse processing completion response: %w", err)
|
||||
}
|
||||
|
||||
// Extract URL and hash from the NIP-94 event
|
||||
var mediaURL string
|
||||
var mediaHash string
|
||||
|
||||
for _, tag := range uploadResp.NIP94Event.Tags {
|
||||
if len(tag) >= 2 {
|
||||
if tag[0] == "url" {
|
||||
mediaURL = tag[1]
|
||||
} else if tag[0] == "ox" || tag[0] == "x" {
|
||||
mediaHash = tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mediaURL == "" {
|
||||
return "", "", errors.New("missing URL in processing completion response")
|
||||
}
|
||||
|
||||
return mediaURL, mediaHash, nil
|
||||
}
|
||||
|
||||
// Parse the processing status
|
||||
var statusResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Percentage int `json:"percentage"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &statusResp); err != nil {
|
||||
u.logger.Warn("Failed to parse processing status response",
|
||||
zap.Error(err),
|
||||
zap.String("body", string(body)),
|
||||
zap.Int("attempt", attempt))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if processing failed
|
||||
if statusResp.Status == "error" {
|
||||
return "", "", fmt.Errorf("processing failed: %s", statusResp.Message)
|
||||
}
|
||||
|
||||
// Log progress
|
||||
u.logger.Info("File processing in progress",
|
||||
zap.String("url", processingURL),
|
||||
zap.Int("percentage", statusResp.Percentage),
|
||||
zap.Int("attempt", attempt))
|
||||
}
|
||||
|
||||
return "", "", errors.New("processing timed out")
|
||||
}
|
||||
|
||||
// DeleteFile deletes a file from the server
|
||||
func (u *Uploader) DeleteFile(fileHash string) error {
|
||||
// Create the delete URL
|
||||
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
// Add NIP-98 auth header if available
|
||||
if u.getAuthHeader != nil {
|
||||
authHeader, err := u.getAuthHeader(deleteURL, "DELETE", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth header: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Send the request
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send delete request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateNIP98AuthHeader creates a NIP-98 authorization header
|
||||
func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (string, error) {
|
||||
// Create the event
|
||||
tags := []nostr.Tag{
|
||||
{"u", url},
|
||||
{"method", method},
|
||||
}
|
||||
|
||||
// Add payload hash if provided
|
||||
if payload != nil {
|
||||
payloadHasher := sha256.New()
|
||||
payloadHasher.Write(payload)
|
||||
payloadHash := hex.EncodeToString(payloadHasher.Sum(nil))
|
||||
tags = append(tags, nostr.Tag{"payload", payloadHash})
|
||||
}
|
||||
|
||||
// Create the auth event
|
||||
authEvent := nostr.Event{
|
||||
Kind: 27235, // NIP-98 HTTP Auth
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: "", // Empty content for auth events
|
||||
}
|
||||
|
||||
// Get the public key
|
||||
pubkey, err := nostr.GetPublicKey(privkey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
authEvent.PubKey = pubkey
|
||||
|
||||
// Sign the event
|
||||
err = authEvent.Sign(privkey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign auth event: %w", err)
|
||||
}
|
||||
|
||||
// Serialize the event
|
||||
eventJSON, err := json.Marshal(authEvent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize auth event: %w", err)
|
||||
}
|
||||
|
||||
// Encode as base64
|
||||
encodedEvent := base64.StdEncoding.EncodeToString(eventJSON)
|
||||
|
||||
// Return the authorization header
|
||||
return "Nostr " + encodedEvent, nil
|
||||
}
|
68
internal/models/bot.go
Normal file
68
internal/models/bot.go
Normal file
@ -0,0 +1,68 @@
|
||||
// internal/models/bot.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bot represents a Nostr posting bot
|
||||
type Bot struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Pubkey string `db:"pubkey" json:"pubkey"`
|
||||
EncryptedPrivkey string `db:"encrypted_privkey" json:"-"` // Encrypted, never sent to client
|
||||
Name string `db:"name" json:"name"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Bio string `db:"bio" json:"bio"`
|
||||
Nip05 string `db:"nip05" json:"nip05"`
|
||||
ZapAddress string `db:"zap_address" json:"zap_address"`
|
||||
ProfilePicture string `db:"profile_picture" json:"profile_picture"`
|
||||
Banner string `db:"banner" json:"banner"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
OwnerPubkey string `db:"owner_pubkey" json:"owner_pubkey"`
|
||||
|
||||
// The following are not stored in the database
|
||||
PostConfig *PostConfig `json:"post_config,omitempty"`
|
||||
MediaConfig *MediaConfig `json:"media_config,omitempty"`
|
||||
Relays []*Relay `json:"relays,omitempty"`
|
||||
}
|
||||
|
||||
// PostConfig represents the posting configuration for a bot
|
||||
type PostConfig struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
BotID int64 `db:"bot_id" json:"-"`
|
||||
Hashtags string `db:"hashtags" json:"hashtags"` // JSON array stored as string
|
||||
IntervalMinutes int `db:"interval_minutes" json:"interval_minutes"`
|
||||
PostTemplate string `db:"post_template" json:"post_template"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
// MediaConfig represents the media upload configuration for a bot
|
||||
type MediaConfig struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
BotID int64 `db:"bot_id" json:"-"`
|
||||
PrimaryService string `db:"primary_service" json:"primary_service"` // "nip94" or "blossom"
|
||||
FallbackService string `db:"fallback_service" json:"fallback_service"`
|
||||
Nip94ServerURL string `db:"nip94_server_url" json:"nip94_server_url"`
|
||||
BlossomServerURL string `db:"blossom_server_url" json:"blossom_server_url"`
|
||||
}
|
||||
|
||||
// Relay represents a Nostr relay configuration
|
||||
type Relay struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
BotID int64 `db:"bot_id" json:"-"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Read bool `db:"read" json:"read"`
|
||||
Write bool `db:"write" json:"write"`
|
||||
}
|
||||
|
||||
// Post represents a post made by the bot
|
||||
type Post struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
BotID int64 `db:"bot_id" json:"bot_id"`
|
||||
ContentFilename string `db:"content_filename" json:"content_filename"`
|
||||
MediaURL string `db:"media_url" json:"media_url"`
|
||||
EventID string `db:"event_id" json:"event_id"`
|
||||
Status string `db:"status" json:"status"` // "pending", "posted", "failed"
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Error string `db:"error" json:"error,omitempty"`
|
||||
}
|
285
internal/nostr/events/events.go
Normal file
285
internal/nostr/events/events.go
Normal file
@ -0,0 +1,285 @@
|
||||
// internal/nostr/events/events.go
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||
)
|
||||
|
||||
// EventManager handles creation and signing of Nostr events
|
||||
type EventManager struct {
|
||||
// The private key getter function returns a private key for the given pubkey
|
||||
getPrivateKey func(pubkey string) (string, error)
|
||||
}
|
||||
|
||||
// NewEventManager creates a new EventManager
|
||||
func NewEventManager(getPrivateKey func(pubkey string) (string, error)) *EventManager {
|
||||
return &EventManager{
|
||||
getPrivateKey: getPrivateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot
|
||||
func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) {
|
||||
// Create the metadata structure
|
||||
metadata := map[string]interface{}{
|
||||
"name": bot.Name,
|
||||
"display_name": bot.DisplayName,
|
||||
"about": bot.Bio,
|
||||
}
|
||||
|
||||
// Add optional fields if they exist
|
||||
if bot.Nip05 != "" {
|
||||
metadata["nip05"] = bot.Nip05
|
||||
}
|
||||
|
||||
if bot.ProfilePicture != "" {
|
||||
metadata["picture"] = bot.ProfilePicture
|
||||
}
|
||||
|
||||
if bot.Banner != "" {
|
||||
metadata["banner"] = bot.Banner
|
||||
}
|
||||
|
||||
// Convert metadata to JSON
|
||||
content, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create the event
|
||||
ev := nostr.Event{
|
||||
Kind: 0,
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: []nostr.Tag{},
|
||||
Content: string(content),
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
if err := em.SignEvent(&ev, bot.Pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// CreateAndSignTextNoteEvent creates and signs a kind 1 text note event
|
||||
func (em *EventManager) CreateAndSignTextNoteEvent(
|
||||
pubkey string,
|
||||
content string,
|
||||
tags []nostr.Tag,
|
||||
) (*nostr.Event, error) {
|
||||
// Create the event
|
||||
ev := nostr.Event{
|
||||
Kind: 1,
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
if err := em.SignEvent(&ev, pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// CreateAndSignMediaEvent creates and signs a kind 1 event with media attachment
|
||||
func (em *EventManager) CreateAndSignMediaEvent(
|
||||
pubkey string,
|
||||
content string,
|
||||
mediaURL string,
|
||||
mediaType string,
|
||||
mediaHash string,
|
||||
altText string,
|
||||
hashtags []string,
|
||||
) (*nostr.Event, error) {
|
||||
// Create the imeta tag for the media
|
||||
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
|
||||
|
||||
// Add hash if available
|
||||
if mediaHash != "" {
|
||||
imeta = append(imeta, "x "+mediaHash)
|
||||
}
|
||||
|
||||
// Add alt text if available
|
||||
if altText != "" {
|
||||
imeta = append(imeta, "alt "+altText)
|
||||
}
|
||||
|
||||
// Create tags
|
||||
tags := []nostr.Tag{imeta}
|
||||
|
||||
// Add hashtags
|
||||
for _, tag := range hashtags {
|
||||
tags = append(tags, nostr.Tag{"t", tag})
|
||||
}
|
||||
|
||||
// Create the event
|
||||
ev := nostr.Event{
|
||||
Kind: 1,
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
if err := em.SignEvent(&ev, pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// CreateAndSignPictureEvent creates and signs a kind 20 picture event (NIP-68)
|
||||
func (em *EventManager) CreateAndSignPictureEvent(
|
||||
pubkey string,
|
||||
title string,
|
||||
description string,
|
||||
mediaURL string,
|
||||
mediaType string,
|
||||
mediaHash string,
|
||||
altText string,
|
||||
hashtags []string,
|
||||
) (*nostr.Event, error) {
|
||||
// Create the imeta tag for the media
|
||||
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
|
||||
|
||||
// Add hash if available
|
||||
if mediaHash != "" {
|
||||
imeta = append(imeta, "x "+mediaHash)
|
||||
}
|
||||
|
||||
// Add alt text if available
|
||||
if altText != "" {
|
||||
imeta = append(imeta, "alt "+altText)
|
||||
}
|
||||
|
||||
// Create tags
|
||||
tags := []nostr.Tag{
|
||||
{"title", title},
|
||||
imeta,
|
||||
}
|
||||
|
||||
// Add media type tag
|
||||
tags = append(tags, nostr.Tag{"m", mediaType})
|
||||
|
||||
// Add media hash tag
|
||||
if mediaHash != "" {
|
||||
tags = append(tags, nostr.Tag{"x", mediaHash})
|
||||
}
|
||||
|
||||
// Add hashtags
|
||||
for _, tag := range hashtags {
|
||||
tags = append(tags, nostr.Tag{"t", tag})
|
||||
}
|
||||
|
||||
// Create the event
|
||||
ev := nostr.Event{
|
||||
Kind: 20, // NIP-68 Picture Event
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: description,
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
if err := em.SignEvent(&ev, pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// CreateAndSignVideoEvent creates and signs a kind 21/22 video event (NIP-71)
|
||||
func (em *EventManager) CreateAndSignVideoEvent(
|
||||
pubkey string,
|
||||
title string,
|
||||
description string,
|
||||
mediaURL string,
|
||||
mediaType string,
|
||||
mediaHash string,
|
||||
previewImageURL string,
|
||||
duration int,
|
||||
altText string,
|
||||
hashtags []string,
|
||||
isShortVideo bool,
|
||||
) (*nostr.Event, error) {
|
||||
// Create the imeta tag for the media
|
||||
imeta := []string{
|
||||
"imeta",
|
||||
"url " + mediaURL,
|
||||
"m " + mediaType,
|
||||
}
|
||||
|
||||
// Add hash if available
|
||||
if mediaHash != "" {
|
||||
imeta = append(imeta, "x "+mediaHash)
|
||||
}
|
||||
|
||||
// Add preview image if available
|
||||
if previewImageURL != "" {
|
||||
imeta = append(imeta, "image "+previewImageURL)
|
||||
}
|
||||
|
||||
// Create tags
|
||||
tags := []nostr.Tag{
|
||||
{"title", title},
|
||||
imeta,
|
||||
}
|
||||
|
||||
// Add duration if available
|
||||
if duration > 0 {
|
||||
tags = append(tags, nostr.Tag{"duration", fmt.Sprintf("%d", duration)})
|
||||
}
|
||||
|
||||
// Add alt text if available
|
||||
if altText != "" {
|
||||
tags = append(tags, nostr.Tag{"alt", altText})
|
||||
}
|
||||
|
||||
// Add hashtags
|
||||
for _, tag := range hashtags {
|
||||
tags = append(tags, nostr.Tag{"t", tag})
|
||||
}
|
||||
|
||||
// Choose the right kind based on video type
|
||||
kind := 21 // Regular video
|
||||
if isShortVideo {
|
||||
kind = 22 // Short video
|
||||
}
|
||||
|
||||
// Create the event
|
||||
ev := nostr.Event{
|
||||
Kind: kind,
|
||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||
Tags: tags,
|
||||
Content: description,
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
if err := em.SignEvent(&ev, pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// SignEvent signs a Nostr event using the private key for the given pubkey
|
||||
func (em *EventManager) SignEvent(ev *nostr.Event, pubkey string) error {
|
||||
// Set the public key
|
||||
ev.PubKey = pubkey
|
||||
|
||||
// Get the private key
|
||||
privkey, err := em.getPrivateKey(pubkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get private key: %w", err)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
return ev.Sign(privkey)
|
||||
}
|
183
internal/nostr/poster/poster.go
Normal file
183
internal/nostr/poster/poster.go
Normal file
@ -0,0 +1,183 @@
|
||||
// internal/nostr/poster/poster.go
|
||||
package poster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/media/prepare"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Poster handles posting content to Nostr
|
||||
type Poster struct {
|
||||
eventMgr *events.EventManager
|
||||
relayMgr *relay.Manager
|
||||
mediaPrep *prepare.Manager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPoster creates a new content poster
|
||||
func NewPoster(
|
||||
eventMgr *events.EventManager,
|
||||
relayMgr *relay.Manager,
|
||||
mediaPrep *prepare.Manager,
|
||||
logger *zap.Logger,
|
||||
) *Poster {
|
||||
if logger == nil {
|
||||
// Create a default logger if none is provided
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
// If we can't create a logger, use a no-op logger
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
return &Poster{
|
||||
eventMgr: eventMgr,
|
||||
relayMgr: relayMgr,
|
||||
mediaPrep: mediaPrep,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// PostContent posts content to Nostr
|
||||
func (p *Poster) PostContent(
|
||||
pubkey string,
|
||||
contentPath string,
|
||||
contentType string,
|
||||
mediaURL string,
|
||||
mediaHash string,
|
||||
caption string,
|
||||
hashtags []string,
|
||||
) error {
|
||||
// Determine the type of content
|
||||
isImage := strings.HasPrefix(contentType, "image/")
|
||||
isVideo := strings.HasPrefix(contentType, "video/")
|
||||
|
||||
// Create alt text if not provided (initialize it here)
|
||||
altText := caption
|
||||
if altText == "" {
|
||||
// Use the filename without extension as a fallback
|
||||
altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
|
||||
}
|
||||
|
||||
// Extract media dimensions if it's an image
|
||||
if isImage {
|
||||
dims, err := p.mediaPrep.GetMediaDimensions(contentPath)
|
||||
if err != nil {
|
||||
p.logger.Warn("Failed to get image dimensions, continuing anyway",
|
||||
zap.String("file", contentPath),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
// Log the dimensions
|
||||
p.logger.Debug("Image dimensions",
|
||||
zap.Int("width", dims.Width),
|
||||
zap.Int("height", dims.Height))
|
||||
|
||||
// Add dimensions to alt text if available
|
||||
if altText != "" {
|
||||
altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var event *nostr.Event
|
||||
var err error
|
||||
|
||||
// Determine the appropriate event kind and create the event
|
||||
if isImage {
|
||||
// Use kind 1 (text note) for images
|
||||
// Create hashtag string for post content
|
||||
var hashtagStr string
|
||||
if len(hashtags) > 0 {
|
||||
hashtagArr := make([]string, len(hashtags))
|
||||
for i, tag := range hashtags {
|
||||
hashtagArr[i] = "#" + tag
|
||||
}
|
||||
hashtagStr = "\n\n" + strings.Join(hashtagArr, " ")
|
||||
}
|
||||
|
||||
content := caption + hashtagStr
|
||||
|
||||
event, err = p.eventMgr.CreateAndSignMediaEvent(
|
||||
pubkey,
|
||||
content,
|
||||
mediaURL,
|
||||
contentType,
|
||||
mediaHash,
|
||||
altText,
|
||||
hashtags,
|
||||
)
|
||||
} else if isVideo {
|
||||
// For videos, determine if it's a short video
|
||||
isShortVideo := false // Just a placeholder, would need logic to determine
|
||||
|
||||
// Create the video event
|
||||
event, err = p.eventMgr.CreateAndSignVideoEvent(
|
||||
pubkey,
|
||||
caption, // Title
|
||||
caption, // Description
|
||||
mediaURL,
|
||||
contentType,
|
||||
mediaHash,
|
||||
"", // Preview image URL
|
||||
0, // Duration
|
||||
altText,
|
||||
hashtags,
|
||||
isShortVideo,
|
||||
)
|
||||
} else {
|
||||
// For other types, use a regular text note with attachment
|
||||
event, err = p.eventMgr.CreateAndSignMediaEvent(
|
||||
pubkey,
|
||||
caption,
|
||||
mediaURL,
|
||||
contentType,
|
||||
mediaHash,
|
||||
altText,
|
||||
hashtags,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create event: %w", err)
|
||||
}
|
||||
|
||||
// Publish the event
|
||||
relays, err := p.relayMgr.PublishEvent(ctx, event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish event: %w", err)
|
||||
}
|
||||
|
||||
p.logger.Info("Published content to relays",
|
||||
zap.String("event_id", event.ID),
|
||||
zap.Strings("relays", relays))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePostContentFunc creates a function for posting content
|
||||
func CreatePostContentFunc(
|
||||
eventMgr *events.EventManager,
|
||||
relayMgr *relay.Manager,
|
||||
mediaPrep *prepare.Manager,
|
||||
logger *zap.Logger,
|
||||
) func(string, string, string, string, string, string, []string) error {
|
||||
poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger)
|
||||
|
||||
return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error {
|
||||
return poster.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags)
|
||||
}
|
||||
}
|
325
internal/nostr/relay/manager.go
Normal file
325
internal/nostr/relay/manager.go
Normal file
@ -0,0 +1,325 @@
|
||||
// internal/nostr/relay/manager.go
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nbd-wtf/go-nostr"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Manager handles connections to Nostr relays
|
||||
type Manager struct {
|
||||
relays map[string]*nostr.Relay
|
||||
readURLs []string
|
||||
writeURLs []string
|
||||
mu sync.RWMutex
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a new relay manager
|
||||
func NewManager(logger *zap.Logger) *Manager {
|
||||
if logger == nil {
|
||||
// Create a default logger if none is provided
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
// If we can't create a logger, use a no-op logger
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
relays: make(map[string]*nostr.Relay),
|
||||
readURLs: []string{},
|
||||
writeURLs: []string{},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// AddRelay adds a relay to the manager and connects to it
|
||||
func (m *Manager) AddRelay(url string, read, write bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if we're already connected to this relay
|
||||
if relay, exists := m.relays[url]; exists {
|
||||
// Update read/write flags
|
||||
if read && !isInSlice(url, m.readURLs) {
|
||||
m.readURLs = append(m.readURLs, url)
|
||||
} else if !read {
|
||||
m.readURLs = removeFromSlice(url, m.readURLs)
|
||||
}
|
||||
|
||||
if write && !isInSlice(url, m.writeURLs) {
|
||||
m.writeURLs = append(m.writeURLs, url)
|
||||
} else if !write {
|
||||
m.writeURLs = removeFromSlice(url, m.writeURLs)
|
||||
}
|
||||
|
||||
// If we don't need to read or write to this relay, close the connection
|
||||
if !read && !write {
|
||||
relay.Close()
|
||||
delete(m.relays, url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only connect if we need to read or write
|
||||
if !read && !write {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect to the relay
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
relay, err := nostr.RelayConnect(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to relay %s: %w", url, err)
|
||||
}
|
||||
|
||||
// Store the relay
|
||||
m.relays[url] = relay
|
||||
|
||||
// Update read/write lists
|
||||
if read {
|
||||
m.readURLs = append(m.readURLs, url)
|
||||
}
|
||||
|
||||
if write {
|
||||
m.writeURLs = append(m.writeURLs, url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRelay removes a relay from the manager and closes the connection
|
||||
func (m *Manager) RemoveRelay(url string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if we're connected to this relay
|
||||
if relay, exists := m.relays[url]; exists {
|
||||
relay.Close()
|
||||
delete(m.relays, url)
|
||||
}
|
||||
|
||||
// Remove from read/write lists
|
||||
m.readURLs = removeFromSlice(url, m.readURLs)
|
||||
m.writeURLs = removeFromSlice(url, m.writeURLs)
|
||||
}
|
||||
|
||||
// PublishEvent publishes an event to all write relays
|
||||
func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]string, error) {
|
||||
m.mu.RLock()
|
||||
writeURLs := make([]string, len(m.writeURLs))
|
||||
copy(writeURLs, m.writeURLs)
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(writeURLs) == 0 {
|
||||
return nil, fmt.Errorf("no write relays configured")
|
||||
}
|
||||
|
||||
// Keep track of successful publishes
|
||||
var successful []string
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Publish to all write relays in parallel
|
||||
for _, url := range writeURLs {
|
||||
wg.Add(1)
|
||||
go func(relayURL string) {
|
||||
defer wg.Done()
|
||||
|
||||
m.mu.RLock()
|
||||
relay, exists := m.relays[relayURL]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new context with timeout
|
||||
publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Publish the event
|
||||
err := relay.Publish(publishCtx, *event)
|
||||
status := err == nil // Assuming no error means success
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to publish event",
|
||||
zap.String("relay", relayURL),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the publish was successful
|
||||
if status {
|
||||
mu.Lock()
|
||||
successful = append(successful, relayURL)
|
||||
mu.Unlock()
|
||||
|
||||
m.logger.Info("Event published successfully",
|
||||
zap.String("relay", relayURL),
|
||||
zap.String("event_id", event.ID))
|
||||
} else {
|
||||
m.logger.Warn("Relay rejected event",
|
||||
zap.String("relay", relayURL),
|
||||
zap.String("event_id", event.ID))
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
|
||||
// Wait for all publish operations to complete
|
||||
wg.Wait()
|
||||
|
||||
if len(successful) == 0 {
|
||||
return nil, fmt.Errorf("failed to publish event to any relay")
|
||||
}
|
||||
|
||||
return successful, nil
|
||||
}
|
||||
|
||||
// SubscribeToEvents subscribes to events matching the given filters
|
||||
func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) {
|
||||
m.mu.RLock()
|
||||
readURLs := make([]string, len(m.readURLs))
|
||||
copy(readURLs, m.readURLs)
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(readURLs) == 0 {
|
||||
return nil, fmt.Errorf("no read relays configured")
|
||||
}
|
||||
|
||||
// Create a channel for events
|
||||
eventChan := make(chan *nostr.Event)
|
||||
|
||||
// Create a new context with timeout for subscriptions
|
||||
subCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Keep track of subscriptions
|
||||
var subscriptions []*nostr.Subscription
|
||||
|
||||
// Subscribe to all read relays
|
||||
var wg sync.WaitGroup
|
||||
for _, url := range readURLs {
|
||||
wg.Add(1)
|
||||
go func(relayURL string) {
|
||||
defer wg.Done()
|
||||
|
||||
m.mu.RLock()
|
||||
relay, exists := m.relays[relayURL]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL))
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
sub, err := relay.Subscribe(subCtx, filters)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to subscribe to relay",
|
||||
zap.String("relay", relayURL),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
subscriptions = append(subscriptions, sub)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Handle events
|
||||
go func() {
|
||||
for ev := range sub.Events {
|
||||
select {
|
||||
case eventChan <- ev:
|
||||
// Event sent to caller
|
||||
case <-subCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}(url)
|
||||
}
|
||||
|
||||
// Wait for all subscriptions to be set up
|
||||
wg.Wait()
|
||||
|
||||
// Return a cleanup function
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
cancel()
|
||||
close(eventChan)
|
||||
|
||||
// Close all subscriptions
|
||||
for _, sub := range subscriptions {
|
||||
sub.Unsub()
|
||||
}
|
||||
}()
|
||||
|
||||
return eventChan, nil
|
||||
}
|
||||
|
||||
// Close closes all relay connections
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for url, relay := range m.relays {
|
||||
relay.Close()
|
||||
delete(m.relays, url)
|
||||
}
|
||||
|
||||
m.readURLs = []string{}
|
||||
m.writeURLs = []string{}
|
||||
}
|
||||
|
||||
// GetRelays returns the list of connected relays
|
||||
func (m *Manager) GetRelays() map[string]struct{ Read, Write bool } {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]struct{ Read, Write bool })
|
||||
|
||||
for url := range m.relays {
|
||||
result[url] = struct {
|
||||
Read bool
|
||||
Write bool
|
||||
}{
|
||||
Read: isInSlice(url, m.readURLs),
|
||||
Write: isInSlice(url, m.writeURLs),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// isInSlice checks if a string is in a slice
|
||||
func isInSlice(s string, slice []string) bool {
|
||||
for _, item := range slice {
|
||||
if item == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// removeFromSlice removes a string from a slice
|
||||
func removeFromSlice(s string, slice []string) []string {
|
||||
var result []string
|
||||
for _, item := range slice {
|
||||
if item != s {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
383
internal/scheduler/scheduler.go
Normal file
383
internal/scheduler/scheduler.go
Normal file
@ -0,0 +1,383 @@
|
||||
// internal/scheduler/scheduler.go
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MediaUploader defines the interface for uploading media
|
||||
type MediaUploader interface {
|
||||
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
||||
DeleteFile(fileHash string) error
|
||||
}
|
||||
|
||||
// PostPublisher defines the interface for publishing posts
|
||||
type PostPublisher interface {
|
||||
PublishEvent(ctx context.Context, event interface{}) ([]string, error)
|
||||
}
|
||||
|
||||
// ContentPoster defines the function to post content
|
||||
type ContentPoster func(
|
||||
pubkey string,
|
||||
contentPath string,
|
||||
contentType string,
|
||||
mediaURL string,
|
||||
mediaHash string,
|
||||
caption string,
|
||||
hashtags []string,
|
||||
) error
|
||||
|
||||
// Scheduler manages scheduled content posting
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
cron *cron.Cron
|
||||
logger *zap.Logger
|
||||
contentDir string
|
||||
archiveDir string
|
||||
nip94Uploader MediaUploader
|
||||
blossomUploader MediaUploader
|
||||
postContent ContentPoster
|
||||
botJobs map[int64]cron.EntryID
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewScheduler creates a new content scheduler
|
||||
func NewScheduler(
|
||||
db *db.DB,
|
||||
logger *zap.Logger,
|
||||
contentDir string,
|
||||
archiveDir string,
|
||||
nip94Uploader MediaUploader,
|
||||
blossomUploader MediaUploader,
|
||||
postContent ContentPoster,
|
||||
) *Scheduler {
|
||||
if logger == nil {
|
||||
// Create a default logger
|
||||
var err error
|
||||
logger, err = zap.NewProduction()
|
||||
if err != nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new cron scheduler with seconds precision
|
||||
cronScheduler := cron.New(cron.WithSeconds())
|
||||
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
cron: cronScheduler,
|
||||
logger: logger,
|
||||
contentDir: contentDir,
|
||||
archiveDir: archiveDir,
|
||||
nip94Uploader: nip94Uploader,
|
||||
blossomUploader: blossomUploader,
|
||||
postContent: postContent,
|
||||
botJobs: make(map[int64]cron.EntryID),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the scheduler
|
||||
func (s *Scheduler) Start() error {
|
||||
// Load all bots with enabled post configs
|
||||
query := `
|
||||
SELECT b.*, pc.*, mc.*
|
||||
FROM bots b
|
||||
JOIN post_config pc ON b.id = pc.bot_id
|
||||
JOIN media_config mc ON b.id = mc.bot_id
|
||||
WHERE pc.enabled = 1
|
||||
`
|
||||
|
||||
rows, err := s.db.Queryx(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load bots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Process each bot
|
||||
for rows.Next() {
|
||||
var bot models.Bot
|
||||
var postConfig models.PostConfig
|
||||
var mediaConfig models.MediaConfig
|
||||
|
||||
// Map the results to our structs
|
||||
err := rows.Scan(
|
||||
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName,
|
||||
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner,
|
||||
&bot.CreatedAt, &bot.OwnerPubkey,
|
||||
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes,
|
||||
&postConfig.PostTemplate, &postConfig.Enabled,
|
||||
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
|
||||
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to scan bot row", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the associated config
|
||||
bot.PostConfig = &postConfig
|
||||
bot.MediaConfig = &mediaConfig
|
||||
|
||||
// Schedule the bot
|
||||
if err := s.ScheduleBot(&bot); err != nil {
|
||||
s.logger.Error("Failed to schedule bot",
|
||||
zap.String("name", bot.Name),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Start the cron scheduler
|
||||
s.cron.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.cron != nil {
|
||||
s.cron.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleBot schedules posting for a specific bot
|
||||
func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Unschedule if already scheduled
|
||||
if entryID, exists := s.botJobs[bot.ID]; exists {
|
||||
s.cron.Remove(entryID)
|
||||
delete(s.botJobs, bot.ID)
|
||||
}
|
||||
|
||||
// Create bot-specific content directory if it doesn't exist
|
||||
botContentDir := filepath.Join(s.contentDir, fmt.Sprintf("bot_%d", bot.ID))
|
||||
if err := utils.EnsureDir(botContentDir); err != nil {
|
||||
return fmt.Errorf("failed to create bot content directory: %w", err)
|
||||
}
|
||||
|
||||
// Create bot-specific archive directory if it doesn't exist
|
||||
botArchiveDir := filepath.Join(s.archiveDir, fmt.Sprintf("bot_%d", bot.ID))
|
||||
if err := utils.EnsureDir(botArchiveDir); err != nil {
|
||||
return fmt.Errorf("failed to create bot archive directory: %w", err)
|
||||
}
|
||||
|
||||
// Parse hashtags
|
||||
var hashtags []string
|
||||
if bot.PostConfig.Hashtags != "" {
|
||||
if err := json.Unmarshal([]byte(bot.PostConfig.Hashtags), &hashtags); err != nil {
|
||||
s.logger.Warn("Failed to parse hashtags",
|
||||
zap.String("hashtags", bot.PostConfig.Hashtags),
|
||||
zap.Error(err))
|
||||
hashtags = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the schedule expression
|
||||
// Format: "0 */X * * * *" where X is the interval in minutes
|
||||
// This runs at 0 seconds, every X minutes
|
||||
schedule := fmt.Sprintf("0 */%d * * * *", bot.PostConfig.IntervalMinutes)
|
||||
|
||||
// Create a job function that captures the bot's information
|
||||
jobFunc := func() {
|
||||
s.logger.Info("Running scheduled post",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("name", bot.Name))
|
||||
|
||||
// Get a random media file
|
||||
contentPath, err := utils.GetRandomFile(botContentDir, utils.GetAllSupportedMediaExtensions())
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get random file",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Get content type
|
||||
contentType, err := utils.GetFileContentType(contentPath)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to determine content type",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Select the appropriate uploader
|
||||
var uploader MediaUploader
|
||||
if bot.MediaConfig.PrimaryService == "blossom" {
|
||||
uploader = s.blossomUploader
|
||||
} else {
|
||||
uploader = s.nip94Uploader
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
mediaURL, mediaHash, err := uploader.UploadFile(contentPath, "", "")
|
||||
if err != nil {
|
||||
// Try fallback if available
|
||||
if bot.MediaConfig.FallbackService != "" && bot.MediaConfig.FallbackService != bot.MediaConfig.PrimaryService {
|
||||
s.logger.Warn("Primary upload failed, trying fallback",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("primary", bot.MediaConfig.PrimaryService),
|
||||
zap.String("fallback", bot.MediaConfig.FallbackService),
|
||||
zap.Error(err))
|
||||
|
||||
if bot.MediaConfig.FallbackService == "blossom" {
|
||||
uploader = s.blossomUploader
|
||||
} else {
|
||||
uploader = s.nip94Uploader
|
||||
}
|
||||
|
||||
mediaURL, mediaHash, err = uploader.UploadFile(contentPath, "", "")
|
||||
if err != nil {
|
||||
s.logger.Error("Fallback upload failed",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
s.logger.Error("File upload failed",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the base filename without extension
|
||||
filename := filepath.Base(contentPath)
|
||||
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
// Post the content
|
||||
err = s.postContent(
|
||||
bot.Pubkey,
|
||||
contentPath,
|
||||
contentType,
|
||||
mediaURL,
|
||||
mediaHash,
|
||||
caption,
|
||||
hashtags,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to post content",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Move the file to the archive directory
|
||||
archivePath := filepath.Join(botArchiveDir, filepath.Base(contentPath))
|
||||
if err := utils.MoveFile(contentPath, archivePath); err != nil {
|
||||
s.logger.Error("Failed to archive file",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.String("archive", archivePath),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("Successfully posted content",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("file", contentPath),
|
||||
zap.String("media_url", mediaURL))
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
entryID, err := s.cron.AddFunc(schedule, jobFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule bot: %w", err)
|
||||
}
|
||||
|
||||
// Store the entry ID
|
||||
s.botJobs[bot.ID] = entryID
|
||||
|
||||
s.logger.Info("Bot scheduled successfully",
|
||||
zap.Int64("bot_id", bot.ID),
|
||||
zap.String("name", bot.Name),
|
||||
zap.String("schedule", schedule))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnscheduleBot removes a bot from the scheduler
|
||||
func (s *Scheduler) UnscheduleBot(botID int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if entryID, exists := s.botJobs[botID]; exists {
|
||||
s.cron.Remove(entryID)
|
||||
delete(s.botJobs, botID)
|
||||
s.logger.Info("Bot unscheduled", zap.Int64("bot_id", botID))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateBotSchedule updates a bot's schedule
|
||||
func (s *Scheduler) UpdateBotSchedule(bot *models.Bot) error {
|
||||
// Unschedule first
|
||||
s.UnscheduleBot(bot.ID)
|
||||
|
||||
// Reschedule with new configuration
|
||||
return s.ScheduleBot(bot)
|
||||
}
|
||||
|
||||
// RunNow triggers an immediate post for a bot
|
||||
func (s *Scheduler) RunNow(botID int64) error {
|
||||
// Load the bot with its configurations
|
||||
var bot models.Bot
|
||||
err := s.db.Get(&bot, "SELECT * FROM bots WHERE id = ?", botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load bot: %w", err)
|
||||
}
|
||||
|
||||
// Load post config
|
||||
var postConfig models.PostConfig
|
||||
err = s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load post config: %w", err)
|
||||
}
|
||||
bot.PostConfig = &postConfig
|
||||
|
||||
// Load media config
|
||||
var mediaConfig models.MediaConfig
|
||||
err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load media config: %w", err)
|
||||
}
|
||||
bot.MediaConfig = &mediaConfig
|
||||
|
||||
// Create job and run it
|
||||
s.ScheduleBot(&bot)
|
||||
|
||||
s.mu.RLock()
|
||||
entryID, exists := s.botJobs[botID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("bot not scheduled")
|
||||
}
|
||||
|
||||
// Get the job
|
||||
entry := s.cron.Entry(entryID)
|
||||
if entry.Job == nil {
|
||||
return fmt.Errorf("job not found")
|
||||
}
|
||||
|
||||
// Run the job
|
||||
entry.Job.Run()
|
||||
|
||||
return nil
|
||||
}
|
183
internal/utils/files.go
Normal file
183
internal/utils/files.go
Normal file
@ -0,0 +1,183 @@
|
||||
// internal/utils/files.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Seed the random number generator
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// EnsureDir makes sure the directory exists
|
||||
func EnsureDir(dir string) error {
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
// FileExists checks if a file exists and is not a directory
|
||||
func FileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// GetRandomFile returns a random file from the specified directory
|
||||
// It only includes files with the given extensions (no dot, e.g. "jpg", "png")
|
||||
// If extensions is empty, it includes all files
|
||||
func GetRandomFile(dir string, extensions []string) (string, error) {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
// Filter files based on extensions
|
||||
var validFiles []string
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(extensions) == 0 {
|
||||
validFiles = append(validFiles, file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(filepath.Ext(file.Name()), ".")
|
||||
for _, validExt := range extensions {
|
||||
if strings.EqualFold(ext, validExt) {
|
||||
validFiles = append(validFiles, file.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(validFiles) == 0 {
|
||||
return "", fmt.Errorf("no valid files found in directory")
|
||||
}
|
||||
|
||||
// Pick a random file
|
||||
randomIndex := rand.Intn(len(validFiles))
|
||||
return filepath.Join(dir, validFiles[randomIndex]), nil
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst
|
||||
func MoveFile(src, dst string) error {
|
||||
// Ensure the destination directory exists
|
||||
if err := EnsureDir(filepath.Dir(dst)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First try to rename (which works within the same filesystem)
|
||||
err := os.Rename(src, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If rename fails (e.g., across filesystems), copy and delete the original
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
// CopyFile copies a file from src to dst
|
||||
func CopyFile(src, dst string) error {
|
||||
// Ensure the destination directory exists
|
||||
if err := EnsureDir(filepath.Dir(dst)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the source file
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
// Create the destination file
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Copy the content
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Flush the write buffer to disk
|
||||
return destFile.Sync()
|
||||
}
|
||||
|
||||
// CalculateFileHash returns the SHA-256 hash of a file
|
||||
func CalculateFileHash(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// GetFileContentType tries to determine the content type of a file
|
||||
func GetFileContentType(filePath string) (string, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the first 512 bytes to determine the content type
|
||||
buffer := make([]byte, 512)
|
||||
_, err = file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Reset the file pointer
|
||||
_, err = file.Seek(0, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Detect the content type
|
||||
contentType := http.DetectContentType(buffer)
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// GetSupportedImageExtensions returns a list of supported image extensions
|
||||
func GetSupportedImageExtensions() []string {
|
||||
return []string{"jpg", "jpeg", "png", "gif", "webp", "avif"}
|
||||
}
|
||||
|
||||
// GetSupportedVideoExtensions returns a list of supported video extensions
|
||||
func GetSupportedVideoExtensions() []string {
|
||||
return []string{"mp4", "webm", "mov", "avi"}
|
||||
}
|
||||
|
||||
// GetAllSupportedMediaExtensions returns all supported media extensions
|
||||
func GetAllSupportedMediaExtensions() []string {
|
||||
return append(GetSupportedImageExtensions(), GetSupportedVideoExtensions()...)
|
||||
}
|
5
keys.json
Normal file
5
keys.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4",
|
||||
"2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504",
|
||||
"67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a"
|
||||
}
|
86
makefile
Normal file
86
makefile
Normal file
@ -0,0 +1,86 @@
|
||||
# Makefile for Nostr Poster
|
||||
|
||||
# Variables
|
||||
BINARY_NAME=nostr-poster
|
||||
BUILD_DIR=./build
|
||||
BIN_DIR=$(BUILD_DIR)/bin
|
||||
CONFIG_DIR=$(BUILD_DIR)/config
|
||||
CONTENT_DIR=$(BUILD_DIR)/content
|
||||
ARCHIVE_DIR=$(BUILD_DIR)/archive
|
||||
DB_DIR=$(BUILD_DIR)/db
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
|
||||
# Main package
|
||||
MAIN_PKG=./cmd/server
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: clean build
|
||||
|
||||
# Build binary
|
||||
.PHONY: build
|
||||
build:
|
||||
mkdir -p $(BIN_DIR)
|
||||
mkdir -p $(CONFIG_DIR)
|
||||
mkdir -p $(CONTENT_DIR)
|
||||
mkdir -p $(ARCHIVE_DIR)
|
||||
mkdir -p $(DB_DIR)
|
||||
# Copy config file if it exists
|
||||
if [ -f ./config/config.yaml ]; then cp ./config/config.yaml $(CONFIG_DIR)/config.yaml; fi
|
||||
$(GOBUILD) -o $(BIN_DIR)/$(BINARY_NAME) $(MAIN_PKG)
|
||||
|
||||
# Run the application
|
||||
.PHONY: run
|
||||
run: build
|
||||
$(BIN_DIR)/$(BINARY_NAME) --config=$(CONFIG_DIR)/config.yaml --db=$(DB_DIR)/nostr-poster.db
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
$(GOCLEAN)
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
# Run tests
|
||||
.PHONY: test
|
||||
test:
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
# Update dependencies
|
||||
.PHONY: deps
|
||||
deps:
|
||||
$(GOGET) -u
|
||||
|
||||
# Install required tools
|
||||
.PHONY: tools
|
||||
tools:
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
# Lint the code
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format the code
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
goimports -w .
|
||||
|
||||
# Show help
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "make - Build the application"
|
||||
@echo "make build - Build the application"
|
||||
@echo "make run - Run the application"
|
||||
@echo "make clean - Clean build artifacts"
|
||||
@echo "make test - Run tests"
|
||||
@echo "make deps - Update dependencies"
|
||||
@echo "make tools - Install required tools"
|
||||
@echo "make lint - Lint the code"
|
||||
@echo "make fmt - Format the code"
|
160
web/assets/css/style.css
Normal file
160
web/assets/css/style.css
Normal file
@ -0,0 +1,160 @@
|
||||
:root {
|
||||
--primary-black: #121212;
|
||||
--secondary-black: #1e1e1e;
|
||||
--primary-gray: #2d2d2d;
|
||||
--secondary-gray: #3d3d3d;
|
||||
--light-gray: #aaaaaa;
|
||||
--primary-purple: #9370DB; /* Medium Purple */
|
||||
--secondary-purple: #7B68EE; /* Medium Slate Blue */
|
||||
--dark-purple: #6A5ACD; /* Slate Blue */
|
||||
--text-color: #e0e0e0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, var(--primary-black) 0%, var(--secondary-black) 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
background-color: var(--primary-gray);
|
||||
border: none;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--primary-purple);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: var(--light-gray) !important;
|
||||
}
|
||||
|
||||
.bot-card {
|
||||
transition: all 0.3s ease-in-out;
|
||||
border-left: 3px solid var(--primary-purple);
|
||||
}
|
||||
|
||||
.bot-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
|
||||
border-left: 3px solid var(--secondary-purple);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: var(--secondary-gray) !important;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: linear-gradient(90deg, var(--primary-black) 0%, var(--dark-purple) 100%) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--text-color) !important;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 10px rgba(147, 112, 219, 0.5);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-purple) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-purple) !important;
|
||||
border-color: var(--primary-purple) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background-color: var(--secondary-purple) !important;
|
||||
border-color: var(--secondary-purple) !important;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #4CAF50 !important;
|
||||
border-color: #4CAF50 !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-gray) !important;
|
||||
border-color: var(--secondary-gray) !important;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pubkey {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--light-gray);
|
||||
background-color: var(--secondary-black);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--secondary-gray);
|
||||
color: var(--text-color);
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
background-color: var(--primary-black);
|
||||
border: 1px solid #444;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
background-color: var(--primary-black);
|
||||
border-color: var(--primary-purple);
|
||||
box-shadow: 0 0 0 0.25rem rgba(147, 112, 219, 0.25);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--light-gray);
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--primary-purple);
|
||||
border-color: var(--primary-purple);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--primary-black);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--primary-purple);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--secondary-purple);
|
||||
}
|
351
web/assets/js/main.js
Normal file
351
web/assets/js/main.js
Normal file
@ -0,0 +1,351 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM Elements
|
||||
const loginButton = document.getElementById('loginButton');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const userPubkey = document.getElementById('userPubkey');
|
||||
const authSection = document.getElementById('auth-section');
|
||||
const mainContent = document.getElementById('main-content');
|
||||
const botsList = document.getElementById('bots-list');
|
||||
const createBotBtn = document.getElementById('create-bot-btn');
|
||||
const saveBotBtn = document.getElementById('save-bot-btn');
|
||||
const generateKeypair = document.getElementById('generateKeypair');
|
||||
const keypairInput = document.getElementById('keypair-input');
|
||||
|
||||
// Bootstrap Modal
|
||||
let createBotModal;
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
createBotModal = new bootstrap.Modal(document.getElementById('createBotModal'));
|
||||
}
|
||||
|
||||
// State
|
||||
let currentUser = null;
|
||||
const API_ENDPOINT = '';
|
||||
|
||||
// Check if already logged in
|
||||
checkAuth();
|
||||
|
||||
// Event Listeners
|
||||
loginButton.addEventListener('click', login);
|
||||
logoutButton.addEventListener('click', logout);
|
||||
createBotBtn.addEventListener('click', showCreateBotModal);
|
||||
saveBotBtn.addEventListener('click', createBot);
|
||||
generateKeypair.addEventListener('change', toggleKeypairInput);
|
||||
|
||||
// Functions
|
||||
async function checkAuth() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
showAuthSection();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
currentUser = data.pubkey;
|
||||
showMainContent();
|
||||
fetchBots();
|
||||
} else {
|
||||
// Token invalid
|
||||
localStorage.removeItem('authToken');
|
||||
showAuthSection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
showAuthSection();
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!window.nostr) {
|
||||
alert('Nostr extension not found. Please install a NIP-07 compatible extension like nos2x or Alby.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's public key
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
|
||||
// Create challenge event for signing
|
||||
const event = {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', 'nostr-poster-auth']],
|
||||
content: 'Authenticate with Nostr Poster'
|
||||
};
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
|
||||
// Send to server for verification
|
||||
const response = await fetch(`${API_ENDPOINT}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey,
|
||||
signature: signedEvent.sig,
|
||||
event: JSON.stringify(signedEvent)
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('authToken', data.token);
|
||||
currentUser = pubkey;
|
||||
showMainContent();
|
||||
fetchBots();
|
||||
} else {
|
||||
alert('Authentication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
alert('Login failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('authToken');
|
||||
currentUser = null;
|
||||
showAuthSection();
|
||||
}
|
||||
|
||||
function showAuthSection() {
|
||||
authSection.classList.remove('d-none');
|
||||
mainContent.classList.add('d-none');
|
||||
loginButton.classList.remove('d-none');
|
||||
userInfo.classList.add('d-none');
|
||||
logoutButton.classList.add('d-none');
|
||||
}
|
||||
|
||||
function showMainContent() {
|
||||
authSection.classList.add('d-none');
|
||||
mainContent.classList.remove('d-none');
|
||||
loginButton.classList.add('d-none');
|
||||
userInfo.classList.remove('d-none');
|
||||
logoutButton.classList.remove('d-none');
|
||||
|
||||
// Truncate pubkey for display
|
||||
const shortPubkey = currentUser.substring(0, 8) + '...' + currentUser.substring(currentUser.length - 4);
|
||||
userPubkey.textContent = shortPubkey;
|
||||
}
|
||||
|
||||
async function fetchBots() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||
headers: {
|
||||
'Authorization': token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const bots = await response.json();
|
||||
renderBots(bots);
|
||||
} else {
|
||||
console.error('Failed to fetch bots');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching bots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBots(bots) {
|
||||
botsList.innerHTML = '';
|
||||
|
||||
if (bots.length === 0) {
|
||||
botsList.innerHTML = `
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#9370DB" class="bi bi-robot mb-3" viewBox="0 0 16 16">
|
||||
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z"/>
|
||||
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z"/>
|
||||
</svg>
|
||||
<p class="lead">You don't have any bots yet.</p>
|
||||
<button class="btn btn-primary mt-3" onclick="document.getElementById('create-bot-btn').click()">
|
||||
<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>
|
||||
Create Your First Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
bots.forEach(bot => {
|
||||
// Create a status badge
|
||||
let statusBadge = '';
|
||||
if (bot.post_config?.enabled) {
|
||||
statusBadge = '<span class="badge bg-success">Active</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge bg-secondary">Inactive</span>';
|
||||
}
|
||||
|
||||
// Generate a profile image based on the bot's pubkey (just a colored square)
|
||||
const profileColor = generateColorFromString(bot.pubkey);
|
||||
const initials = (bot.name || 'Bot').substring(0, 2).toUpperCase();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'col-md-4 mb-4';
|
||||
card.innerHTML = `
|
||||
<div class="card bot-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3" style="width: 50px; height: 50px; background-color: ${profileColor}; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">
|
||||
${initials}
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="card-title mb-0">${bot.name}</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<small class="text-muted">${bot.display_name || ''}</small>
|
||||
<div class="ms-2">${statusBadge}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-text truncate">${bot.bio || 'No bio'}</p>
|
||||
<p class="pubkey">npub...${bot.pubkey.substring(bot.pubkey.length - 8)}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-sm btn-primary view-bot" data-id="${bot.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
||||
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
||||
</svg>
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-sm ${bot.post_config?.enabled ? 'btn-outline-danger' : 'btn-outline-success'}">
|
||||
${bot.post_config?.enabled ?
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill me-1" viewBox="0 0 16 16"><path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/></svg>Pause' :
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill me-1" viewBox="0 0 16 16"><path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg>Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
botsList.appendChild(card);
|
||||
});
|
||||
|
||||
// Helper function to generate a color from a string
|
||||
function generateColorFromString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
// Use the hash to create a hue in the purple range (260-290)
|
||||
const hue = ((hash % 30) + 260) % 360;
|
||||
return `hsl(${hue}, 70%, 60%)`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateBotModal() {
|
||||
// Reset form
|
||||
document.getElementById('create-bot-form').reset();
|
||||
generateKeypair.checked = true;
|
||||
keypairInput.classList.add('d-none');
|
||||
|
||||
// Show modal
|
||||
createBotModal.show();
|
||||
}
|
||||
|
||||
function toggleKeypairInput() {
|
||||
if (generateKeypair.checked) {
|
||||
keypairInput.classList.add('d-none');
|
||||
} else {
|
||||
keypairInput.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
async function createBot() {
|
||||
const name = document.getElementById('botName').value;
|
||||
const displayName = document.getElementById('botDisplayName').value;
|
||||
const bio = document.getElementById('botBio').value;
|
||||
const nip05 = document.getElementById('botNip05').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Bot name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
let pubkey = '';
|
||||
let privkey = '';
|
||||
|
||||
if (!generateKeypair.checked) {
|
||||
pubkey = document.getElementById('botPubkey').value;
|
||||
privkey = document.getElementById('botPrivkey').value;
|
||||
|
||||
if (!pubkey || !privkey) {
|
||||
alert('Both public and private keys are required when not generating a keypair.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
display_name: displayName,
|
||||
bio,
|
||||
nip05,
|
||||
pubkey,
|
||||
encrypted_privkey: privkey
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
createBotModal.hide();
|
||||
fetchBots();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create bot: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating bot:', error);
|
||||
alert('Error creating bot: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const togglePrivkeyBtn = document.getElementById('togglePrivkey');
|
||||
if (togglePrivkeyBtn) {
|
||||
togglePrivkeyBtn.addEventListener('click', function() {
|
||||
const privkeyInput = document.getElementById('botPrivkey');
|
||||
const eyeIcon = this.querySelector('svg');
|
||||
|
||||
if (privkeyInput.type === 'password') {
|
||||
privkeyInput.type = 'text';
|
||||
eyeIcon.innerHTML = `
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
`;
|
||||
} else {
|
||||
privkeyInput.type = 'password';
|
||||
eyeIcon.innerHTML = `
|
||||
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
228
web/index.html
Normal file
228
web/index.html
Normal file
@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nostr Poster</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"
|
||||
class="bi bi-send-fill me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z" />
|
||||
</svg>
|
||||
Nostr Poster
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/#bots">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-robot me-1" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Bots
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/#content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-images me-1" viewBox="0 0 16 16">
|
||||
<path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
|
||||
<path
|
||||
d="M14.002 13a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2V5A2 2 0 0 1 2 3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v8a2 2 0 0 1-1.998 2zM14 2H4a1 1 0 0 0-1 1h9.002a2 2 0 0 1 2 2v7A1 1 0 0 0 15 11V3a1 1 0 0 0-1-1zM2.002 4a1 1 0 0 0-1 1v8l2.646-2.354a.5.5 0 0 1 .63-.062l2.66 1.773 3.71-3.71a.5.5 0 0 1 .577-.094l1.777 1.947V5a1 1 0 0 0-1-1h-10z" />
|
||||
</svg>
|
||||
Content
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item" id="loginButton">
|
||||
<button class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-lightning-fill me-1" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641l2.5-8.5z" />
|
||||
</svg>
|
||||
Login with Nostr
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item d-none" id="userInfo">
|
||||
<span class="nav-link">Welcome, <span id="userPubkey" class="pubkey"></span></span>
|
||||
</li>
|
||||
<li class="nav-item d-none" id="logoutButton">
|
||||
<button class="btn btn-outline-danger">Logout</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div id="auth-section">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="mb-4">Welcome to Nostr Poster</h2>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" fill="#9370DB"
|
||||
class="bi bi-lightning-charge-fill" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="lead mb-4">Automate your Nostr content posting with ease. Schedule posts, manage
|
||||
multiple bots, and more.</p>
|
||||
<p>Please login with your Nostr extension (NIP-07) to manage your bots.</p>
|
||||
<button class="btn btn-primary btn-lg px-4 mt-3"
|
||||
onclick="document.getElementById('loginButton').querySelector('button').click()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-lightning-fill me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641l2.5-8.5z" />
|
||||
</svg>
|
||||
Login with Nostr
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="d-none">
|
||||
<div id="bots-section">
|
||||
<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-robot me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Your Bots
|
||||
</h2>
|
||||
<button id="create-bot-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>
|
||||
Create New Bot
|
||||
</button>
|
||||
</div>
|
||||
<div id="bots-list" class="row">
|
||||
<!-- Bot cards will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Bot Modal -->
|
||||
<div class="modal fade" id="createBotModal" 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-robot me-2" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5ZM3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.58 26.58 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.933.933 0 0 1-.765.935c-.845.147-2.34.346-4.235.346-1.895 0-3.39-.2-4.235-.346A.933.933 0 0 1 3 9.219V8.062Zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a24.767 24.767 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25.286 25.286 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135Z" />
|
||||
<path
|
||||
d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2V1.866ZM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5Z" />
|
||||
</svg>
|
||||
Create New Bot
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="create-bot-form">
|
||||
<div class="mb-3">
|
||||
<label for="botName" class="form-label">Bot Name</label>
|
||||
<input type="text" class="form-control" id="botName" required placeholder="e.g., MyArtBot">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botDisplayName" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="botDisplayName"
|
||||
placeholder="e.g., My Art Posting Bot">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botBio" class="form-label">Bio</label>
|
||||
<textarea class="form-control" id="botBio" rows="3"
|
||||
placeholder="A brief description of your bot..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botNip05" class="form-label">NIP-05 Identifier</label>
|
||||
<input type="text" class="form-control" id="botNip05"
|
||||
placeholder="e.g., bot@yourdomain.com">
|
||||
<div class="form-text">Optional. Used for verification.</div>
|
||||
</div>
|
||||
<hr class="border-secondary">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="generateKeypair" checked>
|
||||
<label class="form-check-label" for="generateKeypair">
|
||||
Generate new keypair
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Recommended. A unique Nostr identity will be created for your bot.
|
||||
</div>
|
||||
</div>
|
||||
<div id="keypair-input" class="d-none">
|
||||
<div class="mb-3">
|
||||
<label for="botPubkey" class="form-label">Public Key (hex)</label>
|
||||
<input type="text" class="form-control" id="botPubkey">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="botPrivkey" class="form-label">Private Key (hex)</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="botPrivkey">
|
||||
<button class="btn btn-secondary" type="button" id="togglePrivkey">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||
<path
|
||||
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Your private key will be encrypted on the server.</div>
|
||||
</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-bot-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>
|
||||
Create Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user