Compare commits

...

No commits in common. "c05d28f9768858a31beab0ac2e5f22d17dacef80" and "964f8128973c484aa74dd5caf993d2aefbb45c9d" have entirely different histories.

31 changed files with 5716 additions and 11 deletions

View File

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 enki
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

145
README.md
View File

@ -1,3 +1,144 @@
# nostr-poster # Nostr Poster
Nostr bot that posts sit to nostr 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>