Project framework is mostly done. see todo

This commit is contained in:
enki 2025-02-28 01:25:45 -08:00
parent 90e4280b2c
commit 964f812897
30 changed files with 5717 additions and 0 deletions

144
README.md
View File

@ -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

Binary file not shown.

25
build/config/config.yaml Normal file
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

193
cmd/server/main.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
}

View 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
}

View 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
View 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"`
}

View 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)
}

View 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)
}
}

View 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
}

View 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
View 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
View File

@ -0,0 +1,5 @@
{
"10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4",
"2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504",
"67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a"
}

86
makefile Normal file
View 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
View 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
View 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
View 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>