Content page mostly working
This commit is contained in:
parent
964f812897
commit
559982b525
Binary file not shown.
@ -1,6 +1,7 @@
|
|||||||
app_name: "Nostr Poster"
|
app_name: "Nostr Poster"
|
||||||
server_port: 8765
|
server_port: 8765
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
|
allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
keys_file: "./keys.json"
|
keys_file: "./keys.json"
|
||||||
|
Binary file not shown.
Binary file not shown.
@ -104,11 +104,8 @@ func main() {
|
|||||||
nil, // Supported types will be discovered
|
nil, // Supported types will be discovered
|
||||||
logger,
|
logger,
|
||||||
func(url, method string, payload []byte) (string, error) {
|
func(url, method string, payload []byte) (string, error) {
|
||||||
// Get the private key for the bot
|
// Replace with a valid bot's public key.
|
||||||
// This is a placeholder - in the real implementation
|
botPubkey := "your_valid_bot_pubkey_here"
|
||||||
// you would need to determine which bot's key to use
|
|
||||||
// based on the context of the upload
|
|
||||||
botPubkey := ""
|
|
||||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -122,11 +119,8 @@ func main() {
|
|||||||
cfg.Media.Blossom.ServerURL,
|
cfg.Media.Blossom.ServerURL,
|
||||||
logger,
|
logger,
|
||||||
func(url, method string) (string, error) {
|
func(url, method string) (string, error) {
|
||||||
// Get the private key for the bot
|
// Replace with the appropriate bot's public key
|
||||||
// This is a placeholder - in the real implementation
|
botPubkey := "your_valid_bot_pubkey_here"
|
||||||
// you would need to determine which bot's key to use
|
|
||||||
// based on the context of the upload
|
|
||||||
botPubkey := ""
|
|
||||||
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
privkey, err := keyStore.GetPrivateKey(botPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
app_name: "Nostr Poster"
|
app_name: "Nostr Poster"
|
||||||
server_port: 8765
|
server_port: 8765
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
|
allowed_npub: "npub1kq4cwqruaj5llguq8hmuj6knwyyke4phqgpqumhl0zyp3ctyacyq9q4zy7"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
keys_file: "./keys.json"
|
keys_file: "./keys.json"
|
||||||
|
1
go.mod
1
go.mod
@ -4,6 +4,7 @@ go 1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
89
go.sum
89
go.sum
@ -1,9 +1,30 @@
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||||
|
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||||
|
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||||
|
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
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/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||||
|
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
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/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
|
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||||
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||||
|
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||||
|
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||||
|
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||||
|
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||||
|
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||||
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||||
|
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
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 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
@ -14,12 +35,18 @@ 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/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 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
|
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
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/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
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/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||||
|
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
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/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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
@ -37,15 +64,32 @@ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaC
|
|||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
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/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
@ -71,6 +115,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:MgL/HPnWSTb5BFCL9RuzYQQpMrTi67MvHem4nWFn47E=
|
||||||
github.com/nbd-wtf/go-nostr v0.50.0/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
|
github.com/nbd-wtf/go-nostr v0.50.0/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||||
|
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||||
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
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/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@ -105,6 +158,7 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
|||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
@ -127,6 +181,9 @@ 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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
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.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
@ -135,23 +192,55 @@ golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsG
|
|||||||
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
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 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
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/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
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.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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
||||||
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
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/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nbd-wtf/go-nostr" // required for key derivation
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
|
"git.sovbit.dev/Enki/nostr-poster/internal/crypto"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||||
@ -18,11 +18,11 @@ import (
|
|||||||
|
|
||||||
// BotService provides functionality for managing bots
|
// BotService provides functionality for managing bots
|
||||||
type BotService struct {
|
type BotService struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
keyStore *crypto.KeyStore
|
keyStore *crypto.KeyStore
|
||||||
eventMgr *events.EventManager
|
eventMgr *events.EventManager
|
||||||
relayMgr *relay.Manager
|
relayMgr *relay.Manager
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBotService creates a new BotService
|
// NewBotService creates a new BotService
|
||||||
@ -34,14 +34,19 @@ func NewBotService(
|
|||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *BotService {
|
) *BotService {
|
||||||
return &BotService{
|
return &BotService{
|
||||||
db: db,
|
db: db,
|
||||||
keyStore: keyStore,
|
keyStore: keyStore,
|
||||||
eventMgr: eventMgr,
|
eventMgr: eventMgr,
|
||||||
relayMgr: relayMgr,
|
relayMgr: relayMgr,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPrivateKey returns the private key for the given pubkey from the keystore.
|
||||||
|
func (s *BotService) GetPrivateKey(pubkey string) (string, error) {
|
||||||
|
return s.keyStore.GetPrivateKey(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserBots lists all bots owned by a user
|
// ListUserBots lists all bots owned by a user
|
||||||
func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) {
|
func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) {
|
||||||
query := `
|
query := `
|
||||||
@ -49,13 +54,13 @@ func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) {
|
|||||||
WHERE b.owner_pubkey = ?
|
WHERE b.owner_pubkey = ?
|
||||||
ORDER BY b.created_at DESC
|
ORDER BY b.created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
var bots []*models.Bot
|
var bots []*models.Bot
|
||||||
err := s.db.Select(&bots, query, ownerPubkey)
|
err := s.db.Select(&bots, query, ownerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list bots: %w", err)
|
return nil, fmt.Errorf("failed to list bots: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load associated data for each bot
|
// Load associated data for each bot
|
||||||
for _, bot := range bots {
|
for _, bot := range bots {
|
||||||
if err := s.loadBotRelatedData(bot); err != nil {
|
if err := s.loadBotRelatedData(bot); err != nil {
|
||||||
@ -64,7 +69,7 @@ func (s *BotService) ListUserBots(ownerPubkey string) ([]*models.Bot, error) {
|
|||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bots, nil
|
return bots, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,18 +79,18 @@ func (s *BotService) GetBotByID(botID int64, ownerPubkey string) (*models.Bot, e
|
|||||||
SELECT b.* FROM bots b
|
SELECT b.* FROM bots b
|
||||||
WHERE b.id = ? AND b.owner_pubkey = ?
|
WHERE b.id = ? AND b.owner_pubkey = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
var bot models.Bot
|
var bot models.Bot
|
||||||
err := s.db.Get(&bot, query, botID, ownerPubkey)
|
err := s.db.Get(&bot, query, botID, ownerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get bot: %w", err)
|
return nil, fmt.Errorf("failed to get bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load associated data
|
// Load associated data
|
||||||
if err := s.loadBotRelatedData(&bot); err != nil {
|
if err := s.loadBotRelatedData(&bot); err != nil {
|
||||||
return &bot, fmt.Errorf("failed to load related data: %w", err)
|
return &bot, fmt.Errorf("failed to load related data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &bot, nil
|
return &bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,32 +99,72 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
// Start a transaction
|
// Start a transaction
|
||||||
tx, err := s.db.Beginx()
|
tx, err := s.db.Beginx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to start transaction", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to start transaction: %w", err)
|
return nil, fmt.Errorf("failed to start transaction: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
s.logger.Info("Creating new bot",
|
||||||
|
zap.String("name", bot.Name),
|
||||||
|
zap.String("privkey_type", detectKeyType(bot.EncryptedPrivkey)))
|
||||||
|
|
||||||
// Check if we need to generate a keypair
|
// Check if we need to generate a keypair
|
||||||
if bot.Pubkey == "" {
|
if bot.Pubkey == "" && bot.EncryptedPrivkey == "" {
|
||||||
// Generate a new keypair
|
// Generate a new keypair
|
||||||
|
s.logger.Info("Generating new keypair")
|
||||||
pubkey, privkey, err := s.keyStore.GenerateKey()
|
pubkey, privkey, err := s.keyStore.GenerateKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate keypair", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to generate keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.Pubkey = pubkey
|
bot.Pubkey = pubkey
|
||||||
bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore
|
bot.EncryptedPrivkey = privkey // This will be encrypted by the KeyStore
|
||||||
|
s.logger.Info("Generated keypair successfully", zap.String("pubkey", pubkey))
|
||||||
} else if bot.EncryptedPrivkey != "" {
|
} else if bot.EncryptedPrivkey != "" {
|
||||||
|
// If only privkey is provided, derive the pubkey
|
||||||
|
if bot.Pubkey == "" {
|
||||||
|
privkey := bot.EncryptedPrivkey
|
||||||
|
s.logger.Info("Deriving pubkey from provided privkey",
|
||||||
|
zap.String("key_type", detectKeyType(privkey)))
|
||||||
|
|
||||||
|
if len(privkey) > 4 && privkey[:4] == "nsec" {
|
||||||
|
s.logger.Debug("Decoding nsec key")
|
||||||
|
var decodeErr error
|
||||||
|
privkey, decodeErr = s.keyStore.DecodeNsecKey(privkey)
|
||||||
|
if decodeErr != nil {
|
||||||
|
s.logger.Error("Failed to decode nsec key", zap.Error(decodeErr))
|
||||||
|
return nil, fmt.Errorf("failed to decode nsec key: %w", decodeErr)
|
||||||
|
}
|
||||||
|
s.logger.Debug("Successfully decoded nsec key to hex format")
|
||||||
|
}
|
||||||
|
|
||||||
|
derivedPub, err := nostr.GetPublicKey(privkey)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Invalid private key", zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("invalid private key: %w", err)
|
||||||
|
}
|
||||||
|
bot.Pubkey = derivedPub
|
||||||
|
s.logger.Info("Successfully derived pubkey", zap.String("pubkey", derivedPub))
|
||||||
|
}
|
||||||
|
|
||||||
// Import the provided keypair
|
// Import the provided keypair
|
||||||
|
s.logger.Debug("Importing keypair to keystore")
|
||||||
if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil {
|
if err := s.keyStore.AddKey(bot.Pubkey, bot.EncryptedPrivkey); err != nil {
|
||||||
|
s.logger.Error("Failed to import keypair", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to import keypair: %w", err)
|
return nil, fmt.Errorf("failed to import keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
s.logger.Debug("Successfully imported keypair to keystore")
|
||||||
} else {
|
} else {
|
||||||
|
s.logger.Error("Missing key information",
|
||||||
|
zap.Bool("has_pubkey", bot.Pubkey != ""),
|
||||||
|
zap.Bool("has_privkey", bot.EncryptedPrivkey != ""))
|
||||||
return nil, errors.New("either provide both pubkey and privkey or none for auto-generation")
|
return nil, errors.New("either provide both pubkey and privkey or none for auto-generation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set created time
|
// Set created time
|
||||||
bot.CreatedAt = time.Now()
|
bot.CreatedAt = time.Now()
|
||||||
|
|
||||||
// Insert the bot
|
// Insert the bot
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO bots (
|
INSERT INTO bots (
|
||||||
@ -127,7 +172,7 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
profile_picture, banner, created_at, owner_pubkey
|
profile_picture, banner, created_at, owner_pubkey
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := tx.Exec(
|
result, err := tx.Exec(
|
||||||
query,
|
query,
|
||||||
bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio,
|
bot.Pubkey, bot.EncryptedPrivkey, bot.Name, bot.DisplayName, bot.Bio,
|
||||||
@ -135,16 +180,18 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
bot.CreatedAt, bot.OwnerPubkey,
|
bot.CreatedAt, bot.OwnerPubkey,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert bot", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to insert bot: %w", err)
|
return nil, fmt.Errorf("failed to insert bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the inserted ID
|
// Get the inserted ID
|
||||||
botID, err := result.LastInsertId()
|
botID, err := result.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get inserted ID", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to get inserted ID: %w", err)
|
return nil, fmt.Errorf("failed to get inserted ID: %w", err)
|
||||||
}
|
}
|
||||||
bot.ID = botID
|
bot.ID = botID
|
||||||
|
|
||||||
// Create default post config
|
// Create default post config
|
||||||
postConfig := &models.PostConfig{
|
postConfig := &models.PostConfig{
|
||||||
BotID: botID,
|
BotID: botID,
|
||||||
@ -153,22 +200,23 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
PostTemplate: "",
|
PostTemplate: "",
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
postConfigQuery := `
|
postConfigQuery := `
|
||||||
INSERT INTO post_config (
|
INSERT INTO post_config (
|
||||||
bot_id, hashtags, interval_minutes, post_template, enabled
|
bot_id, hashtags, interval_minutes, post_template, enabled
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
postConfigQuery,
|
postConfigQuery,
|
||||||
postConfig.BotID, postConfig.Hashtags, postConfig.IntervalMinutes,
|
postConfig.BotID, postConfig.Hashtags, postConfig.IntervalMinutes,
|
||||||
postConfig.PostTemplate, postConfig.Enabled,
|
postConfig.PostTemplate, postConfig.Enabled,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert post config", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to insert post config: %w", err)
|
return nil, fmt.Errorf("failed to insert post config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default media config
|
// Create default media config
|
||||||
mediaConfig := &models.MediaConfig{
|
mediaConfig := &models.MediaConfig{
|
||||||
BotID: botID,
|
BotID: botID,
|
||||||
@ -177,22 +225,23 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
Nip94ServerURL: "",
|
Nip94ServerURL: "",
|
||||||
BlossomServerURL: "",
|
BlossomServerURL: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaConfigQuery := `
|
mediaConfigQuery := `
|
||||||
INSERT INTO media_config (
|
INSERT INTO media_config (
|
||||||
bot_id, primary_service, fallback_service, nip94_server_url, blossom_server_url
|
bot_id, primary_service, fallback_service, nip94_server_url, blossom_server_url
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
mediaConfigQuery,
|
mediaConfigQuery,
|
||||||
mediaConfig.BotID, mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
mediaConfig.BotID, mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
||||||
mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL,
|
mediaConfig.Nip94ServerURL, mediaConfig.BlossomServerURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert media config", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to insert media config: %w", err)
|
return nil, fmt.Errorf("failed to insert media config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default relays
|
// Add default relays
|
||||||
defaultRelays := []struct {
|
defaultRelays := []struct {
|
||||||
URL string
|
URL string
|
||||||
@ -203,24 +252,28 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
{"wss://nostr.mutinywallet.com", true, true},
|
{"wss://nostr.mutinywallet.com", true, true},
|
||||||
{"wss://relay.nostr.band", true, true},
|
{"wss://relay.nostr.band", true, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
relayQuery := `
|
relayQuery := `
|
||||||
INSERT INTO relays (bot_id, url, read, write)
|
INSERT INTO relays (bot_id, url, read, write)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
for _, relay := range defaultRelays {
|
for _, relay := range defaultRelays {
|
||||||
_, err = tx.Exec(relayQuery, botID, relay.URL, relay.Read, relay.Write)
|
_, err = tx.Exec(relayQuery, botID, relay.URL, relay.Read, relay.Write)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert relay",
|
||||||
|
zap.String("url", relay.URL),
|
||||||
|
zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to insert relay: %w", err)
|
return nil, fmt.Errorf("failed to insert relay: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
s.logger.Error("Failed to commit transaction", zap.Error(err))
|
||||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load associated data
|
// Load associated data
|
||||||
bot.PostConfig = postConfig
|
bot.PostConfig = postConfig
|
||||||
bot.MediaConfig = mediaConfig
|
bot.MediaConfig = mediaConfig
|
||||||
@ -229,7 +282,12 @@ func (s *BotService) CreateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
|
{BotID: botID, URL: "wss://nostr.mutinywallet.com", Read: true, Write: true},
|
||||||
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
|
{BotID: botID, URL: "wss://relay.nostr.band", Read: true, Write: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Bot created successfully",
|
||||||
|
zap.Int64("id", botID),
|
||||||
|
zap.String("name", bot.Name),
|
||||||
|
zap.String("pubkey", bot.Pubkey))
|
||||||
|
|
||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,7 +298,7 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't update the pubkey or encrypted_privkey
|
// We don't update the pubkey or encrypted_privkey
|
||||||
query := `
|
query := `
|
||||||
UPDATE bots SET
|
UPDATE bots SET
|
||||||
@ -253,23 +311,23 @@ func (s *BotService) UpdateBot(bot *models.Bot) (*models.Bot, error) {
|
|||||||
banner = ?
|
banner = ?
|
||||||
WHERE id = ? AND owner_pubkey = ?
|
WHERE id = ? AND owner_pubkey = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
query,
|
query,
|
||||||
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
|
bot.Name, bot.DisplayName, bot.Bio, bot.Nip05,
|
||||||
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
bot.ZapAddress, bot.ProfilePicture, bot.Banner,
|
||||||
bot.ID, bot.OwnerPubkey,
|
bot.ID, bot.OwnerPubkey,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to update bot: %w", err)
|
return nil, fmt.Errorf("failed to update bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the updated bot
|
// Get the updated bot
|
||||||
updatedBot, err := s.GetBotByID(bot.ID, bot.OwnerPubkey)
|
updatedBot, err := s.GetBotByID(bot.ID, bot.OwnerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve updated bot: %w", err)
|
return nil, fmt.Errorf("failed to retrieve updated bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedBot, nil
|
return updatedBot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,26 +338,26 @@ func (s *BotService) DeleteBot(botID int64, ownerPubkey string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction
|
// Start a transaction
|
||||||
tx, err := s.db.Beginx()
|
tx, err := s.db.Beginx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start transaction: %w", err)
|
return fmt.Errorf("failed to start transaction: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Delete the bot (cascade will handle related tables)
|
// Delete the bot (cascade will handle related tables)
|
||||||
query := `DELETE FROM bots WHERE id = ? AND owner_pubkey = ?`
|
query := `DELETE FROM bots WHERE id = ? AND owner_pubkey = ?`
|
||||||
_, err = tx.Exec(query, botID, ownerPubkey)
|
_, err = tx.Exec(query, botID, ownerPubkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete bot: %w", err)
|
return fmt.Errorf("failed to delete bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,14 +373,14 @@ func (s *BotService) UpdateBotConfig(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction
|
// Start a transaction
|
||||||
tx, err := s.db.Beginx()
|
tx, err := s.db.Beginx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start transaction: %w", err)
|
return fmt.Errorf("failed to start transaction: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Update post config if provided
|
// Update post config if provided
|
||||||
if postConfig != nil {
|
if postConfig != nil {
|
||||||
query := `
|
query := `
|
||||||
@ -333,7 +391,7 @@ func (s *BotService) UpdateBotConfig(
|
|||||||
enabled = ?
|
enabled = ?
|
||||||
WHERE bot_id = ?
|
WHERE bot_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
query,
|
query,
|
||||||
postConfig.Hashtags, postConfig.IntervalMinutes,
|
postConfig.Hashtags, postConfig.IntervalMinutes,
|
||||||
@ -344,7 +402,7 @@ func (s *BotService) UpdateBotConfig(
|
|||||||
return fmt.Errorf("failed to update post config: %w", err)
|
return fmt.Errorf("failed to update post config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update media config if provided
|
// Update media config if provided
|
||||||
if mediaConfig != nil {
|
if mediaConfig != nil {
|
||||||
query := `
|
query := `
|
||||||
@ -355,7 +413,7 @@ func (s *BotService) UpdateBotConfig(
|
|||||||
blossom_server_url = ?
|
blossom_server_url = ?
|
||||||
WHERE bot_id = ?
|
WHERE bot_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
query,
|
query,
|
||||||
mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
mediaConfig.PrimaryService, mediaConfig.FallbackService,
|
||||||
@ -366,12 +424,12 @@ func (s *BotService) UpdateBotConfig(
|
|||||||
return fmt.Errorf("failed to update media config: %w", err)
|
return fmt.Errorf("failed to update media config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,7 +440,7 @@ func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the relays
|
// Get the relays
|
||||||
query := `
|
query := `
|
||||||
SELECT id, bot_id, url, read, write
|
SELECT id, bot_id, url, read, write
|
||||||
@ -390,13 +448,13 @@ func (s *BotService) GetBotRelays(botID int64, ownerPubkey string) ([]*models.Re
|
|||||||
WHERE bot_id = ?
|
WHERE bot_id = ?
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
`
|
`
|
||||||
|
|
||||||
var relays []*models.Relay
|
var relays []*models.Relay
|
||||||
err = s.db.Select(&relays, query, botID)
|
err = s.db.Select(&relays, query, botID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get relays: %w", err)
|
return nil, fmt.Errorf("failed to get relays: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return relays, nil
|
return relays, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,20 +465,20 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a transaction
|
// Start a transaction
|
||||||
tx, err := s.db.Beginx()
|
tx, err := s.db.Beginx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start transaction: %w", err)
|
return fmt.Errorf("failed to start transaction: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Delete existing relays
|
// Delete existing relays
|
||||||
_, err = tx.Exec("DELETE FROM relays WHERE bot_id = ?", botID)
|
_, err = tx.Exec("DELETE FROM relays WHERE bot_id = ?", botID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete existing relays: %w", err)
|
return fmt.Errorf("failed to delete existing relays: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new relays
|
// Insert new relays
|
||||||
for _, relay := range relays {
|
for _, relay := range relays {
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
@ -431,12 +489,12 @@ func (s *BotService) UpdateBotRelays(botID int64, ownerPubkey string, relays []*
|
|||||||
return fmt.Errorf("failed to insert relay: %w", err)
|
return fmt.Errorf("failed to insert relay: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,13 +505,13 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
return fmt.Errorf("bot not found or not owned by user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and sign the metadata event
|
// Create and sign the metadata event
|
||||||
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create metadata event: %w", err)
|
return fmt.Errorf("failed to create metadata event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up relay connections
|
// Set up relay connections
|
||||||
for _, relay := range bot.Relays {
|
for _, relay := range bot.Relays {
|
||||||
if relay.Write {
|
if relay.Write {
|
||||||
@ -464,20 +522,20 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish the event
|
// Publish the event
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
published, err := s.relayMgr.PublishEvent(ctx, event)
|
published, err := s.relayMgr.PublishEvent(ctx, event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to publish profile: %w", err)
|
return fmt.Errorf("failed to publish profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("Published profile to relays",
|
s.logger.Info("Published profile to relays",
|
||||||
zap.Int64("botID", botID),
|
zap.Int64("botID", botID),
|
||||||
zap.Strings("relays", published))
|
zap.Strings("relays", published))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,7 +548,7 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
|
|||||||
return fmt.Errorf("failed to load post config: %w", err)
|
return fmt.Errorf("failed to load post config: %w", err)
|
||||||
}
|
}
|
||||||
bot.PostConfig = &postConfig
|
bot.PostConfig = &postConfig
|
||||||
|
|
||||||
// Load media config
|
// Load media config
|
||||||
var mediaConfig models.MediaConfig
|
var mediaConfig models.MediaConfig
|
||||||
err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", bot.ID)
|
err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", bot.ID)
|
||||||
@ -498,7 +556,7 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
|
|||||||
return fmt.Errorf("failed to load media config: %w", err)
|
return fmt.Errorf("failed to load media config: %w", err)
|
||||||
}
|
}
|
||||||
bot.MediaConfig = &mediaConfig
|
bot.MediaConfig = &mediaConfig
|
||||||
|
|
||||||
// Load relays
|
// Load relays
|
||||||
var relays []*models.Relay
|
var relays []*models.Relay
|
||||||
err = s.db.Select(&relays, "SELECT * FROM relays WHERE bot_id = ?", bot.ID)
|
err = s.db.Select(&relays, "SELECT * FROM relays WHERE bot_id = ?", bot.ID)
|
||||||
@ -506,6 +564,24 @@ func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
|
|||||||
return fmt.Errorf("failed to load relays: %w", err)
|
return fmt.Errorf("failed to load relays: %w", err)
|
||||||
}
|
}
|
||||||
bot.Relays = relays
|
bot.Relays = relays
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to detect key type for logging
|
||||||
|
func detectKeyType(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return "empty"
|
||||||
|
}
|
||||||
|
if len(key) > 4 {
|
||||||
|
if key[:4] == "nsec" {
|
||||||
|
return "nsec"
|
||||||
|
} else if key[:4] == "npub" {
|
||||||
|
return "npub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(key) == 64 {
|
||||||
|
return "hex"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
@ -2,13 +2,23 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
|
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
||||||
|
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom"
|
||||||
|
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -79,6 +89,9 @@ func (a *API) setupRoutes() {
|
|||||||
botGroup.POST("/:id/run", a.runBotNow)
|
botGroup.POST("/:id/run", a.runBotNow)
|
||||||
botGroup.POST("/:id/enable", a.enableBot)
|
botGroup.POST("/:id/enable", a.enableBot)
|
||||||
botGroup.POST("/:id/disable", a.disableBot)
|
botGroup.POST("/:id/disable", a.disableBot)
|
||||||
|
|
||||||
|
// NEW: Manual post creation
|
||||||
|
botGroup.POST("/:id/post", a.createManualPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content management
|
// Content management
|
||||||
@ -88,6 +101,9 @@ func (a *API) setupRoutes() {
|
|||||||
contentGroup.GET("/:botId", a.listBotContent)
|
contentGroup.GET("/:botId", a.listBotContent)
|
||||||
contentGroup.POST("/:botId/upload", a.uploadContent)
|
contentGroup.POST("/:botId/upload", a.uploadContent)
|
||||||
contentGroup.DELETE("/:botId/:filename", a.deleteContent)
|
contentGroup.DELETE("/:botId/:filename", a.deleteContent)
|
||||||
|
|
||||||
|
// NEW: Media server upload
|
||||||
|
contentGroup.POST("/:botId/uploadToMediaServer", a.uploadToMediaServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
@ -100,6 +116,7 @@ func (a *API) setupRoutes() {
|
|||||||
|
|
||||||
// Serve the web UI
|
// Serve the web UI
|
||||||
a.router.StaticFile("/", "./web/index.html")
|
a.router.StaticFile("/", "./web/index.html")
|
||||||
|
a.router.StaticFile("/content.html", "./web/content.html")
|
||||||
a.router.Static("/assets", "./web/assets")
|
a.router.Static("/assets", "./web/assets")
|
||||||
|
|
||||||
// Handle 404s for SPA
|
// Handle 404s for SPA
|
||||||
@ -198,27 +215,41 @@ func (a *API) listBots(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, bots)
|
c.JSON(http.StatusOK, bots)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createBot creates a new bot
|
|
||||||
func (a *API) createBot(c *gin.Context) {
|
func (a *API) createBot(c *gin.Context) {
|
||||||
pubkey := c.GetString("pubkey")
|
pubkey := c.GetString("pubkey")
|
||||||
|
a.logger.Info("Bot creation request received", zap.String("owner_pubkey", pubkey))
|
||||||
|
|
||||||
var bot models.Bot
|
var bot models.Bot
|
||||||
if err := c.ShouldBindJSON(&bot); err != nil {
|
if err := c.ShouldBindJSON(&bot); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"})
|
a.logger.Error("Invalid bot data", zap.Error(err))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log received data
|
||||||
|
a.logger.Info("Received bot creation data",
|
||||||
|
zap.String("name", bot.Name),
|
||||||
|
zap.String("display_name", bot.DisplayName),
|
||||||
|
zap.Bool("has_privkey", bot.EncryptedPrivkey != ""),
|
||||||
|
zap.Bool("has_pubkey", bot.Pubkey != ""))
|
||||||
|
|
||||||
// Set the owner
|
// Set the owner
|
||||||
bot.OwnerPubkey = pubkey
|
bot.OwnerPubkey = pubkey
|
||||||
|
|
||||||
// Create the bot
|
// Create the bot
|
||||||
createdBot, err := a.botService.CreateBot(&bot)
|
createdBot, err := a.botService.CreateBot(&bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot"})
|
|
||||||
a.logger.Error("Failed to create bot", zap.Error(err))
|
a.logger.Error("Failed to create bot", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.logger.Info("Bot created successfully",
|
||||||
|
zap.Int64("bot_id", createdBot.ID),
|
||||||
|
zap.String("name", createdBot.Name))
|
||||||
|
|
||||||
|
createdBot.EncryptedPrivkey = ""
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, createdBot)
|
c.JSON(http.StatusCreated, createdBot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -556,20 +587,401 @@ func (a *API) disableBot(c *gin.Context) {
|
|||||||
|
|
||||||
// listBotContent lists the content files for a bot
|
// listBotContent lists the content files for a bot
|
||||||
func (a *API) listBotContent(c *gin.Context) {
|
func (a *API) listBotContent(c *gin.Context) {
|
||||||
// This will be implemented when we add content management
|
pubkey := c.GetString("pubkey")
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
|
||||||
|
botIDStr := c.Param("botId")
|
||||||
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path to the bot's content folder
|
||||||
|
// e.g. content/bot_123
|
||||||
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
||||||
|
// If the folder doesn't exist yet, return empty
|
||||||
|
if _, err := os.Stat(contentDir); os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusOK, []string{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the directory
|
||||||
|
entries, err := os.ReadDir(contentDir)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to read content folder", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read content folder"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect filenames
|
||||||
|
var files []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
// skip subdirs if you want, or handle them
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadContent uploads content for a bot
|
// uploadContent uploads content for a bot
|
||||||
func (a *API) uploadContent(c *gin.Context) {
|
func (a *API) uploadContent(c *gin.Context) {
|
||||||
// This will be implemented when we add content management
|
pubkey := c.GetString("pubkey")
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
|
||||||
|
botIDStr := c.Param("botId")
|
||||||
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the bot belongs to user
|
||||||
|
_, err = a.botService.GetBotByID(botID, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file from the form data
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No file in request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path: e.g. content/bot_123
|
||||||
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
||||||
|
// Ensure the directory exists
|
||||||
|
if err := os.MkdirAll(contentDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot folder"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination path
|
||||||
|
destPath := filepath.Join(contentDir, file.Filename)
|
||||||
|
|
||||||
|
// Save the file
|
||||||
|
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
||||||
|
a.logger.Error("Failed to save uploaded file", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "File uploaded successfully", "filename": file.Filename})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteContent deletes content for a bot
|
// deleteContent deletes content for a bot
|
||||||
func (a *API) deleteContent(c *gin.Context) {
|
func (a *API) deleteContent(c *gin.Context) {
|
||||||
// This will be implemented when we add content management
|
pubkey := c.GetString("pubkey")
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
|
||||||
|
botIDStr := c.Param("botId")
|
||||||
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := c.Param("filename")
|
||||||
|
if filename == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the bot belongs to user
|
||||||
|
_, err = a.botService.GetBotByID(botID, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path
|
||||||
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
||||||
|
filePath := filepath.Join(contentDir, filename)
|
||||||
|
|
||||||
|
// Remove the file
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
} else {
|
||||||
|
a.logger.Error("Failed to delete file", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToMediaServer handles uploading a file from the server to a media server
|
||||||
|
func (a *API) uploadToMediaServer(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
botIDStr := c.Param("botId")
|
||||||
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the bot to get its details (including its pubkey and media config)
|
||||||
|
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body.
|
||||||
|
var req struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
Service string `json:"service" binding:"required"`
|
||||||
|
ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL.
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build path to the file
|
||||||
|
contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID))
|
||||||
|
filePath := filepath.Join(contentDir, req.Filename)
|
||||||
|
|
||||||
|
// Verify the file exists
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new uploader instance that uses the bot's key.
|
||||||
|
var uploader scheduler.MediaUploader
|
||||||
|
if req.Service == "blossom" {
|
||||||
|
// Get the base Blossom server URL
|
||||||
|
serverURL := bot.MediaConfig.BlossomServerURL
|
||||||
|
if req.ServerURL != "" {
|
||||||
|
serverURL = req.ServerURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the URL for debugging purposes
|
||||||
|
a.logger.Info("Creating Blossom uploader with server URL",
|
||||||
|
zap.String("original_url", serverURL))
|
||||||
|
|
||||||
|
// According to BUD-02 specification, the upload endpoint should be /upload
|
||||||
|
// Make sure the URL ends with /upload
|
||||||
|
if !strings.HasSuffix(serverURL, "/upload") {
|
||||||
|
serverURL = strings.TrimSuffix(serverURL, "/") + "/upload"
|
||||||
|
a.logger.Info("Adding /upload endpoint to URL",
|
||||||
|
zap.String("complete_url", serverURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
uploader = blossom.NewUploader(
|
||||||
|
serverURL,
|
||||||
|
a.logger,
|
||||||
|
func(url, method string) (string, error) {
|
||||||
|
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return blossom.CreateBlossomAuthHeader(url, method, privkey)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if req.Service == "nip94" {
|
||||||
|
serverURL := bot.MediaConfig.Nip94ServerURL
|
||||||
|
if req.ServerURL != "" {
|
||||||
|
serverURL = req.ServerURL
|
||||||
|
}
|
||||||
|
uploader = nip94.NewUploader(
|
||||||
|
serverURL,
|
||||||
|
"", // Download URL will be discovered
|
||||||
|
nil, // Supported types will be discovered
|
||||||
|
a.logger,
|
||||||
|
func(url, method string, payload []byte) (string, error) {
|
||||||
|
privkey, err := a.botService.GetPrivateKey(bot.Pubkey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return nip94.CreateNIP98AuthHeader(url, method, payload, privkey)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
|
||||||
|
altText := caption
|
||||||
|
|
||||||
|
mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to upload to media server",
|
||||||
|
zap.String("file", filePath),
|
||||||
|
zap.String("service", req.Service),
|
||||||
|
zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload to media server: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"url": mediaURL,
|
||||||
|
"hash": mediaHash,
|
||||||
|
"service": req.Service,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated createManualPost function in routes.go to properly process kind 20 posts
|
||||||
|
func (a *API) createManualPost(c *gin.Context) {
|
||||||
|
pubkey := c.GetString("pubkey")
|
||||||
|
botIDStr := c.Param("id")
|
||||||
|
botID, err := strconv.ParseInt(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the bot belongs to the user
|
||||||
|
bot, err := a.botService.GetBotByID(botID, pubkey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
Kind int `json:"kind" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Alt string `json:"alt"` // Added to support alt text for images
|
||||||
|
Hashtags []string `json:"hashtags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For kind 20 (picture post), title is required
|
||||||
|
if req.Kind == 20 && req.Title == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process content to extract media URLs
|
||||||
|
var mediaURL string
|
||||||
|
var mediaType string
|
||||||
|
var mediaHash string
|
||||||
|
|
||||||
|
// Check if content contains URLs
|
||||||
|
re := regexp.MustCompile(`https?://[^\s]+`)
|
||||||
|
matches := re.FindAllString(req.Content, -1)
|
||||||
|
|
||||||
|
if len(matches) > 0 {
|
||||||
|
mediaURL = matches[0] // Use the first URL found
|
||||||
|
|
||||||
|
// Try to determine media type - this is a placeholder
|
||||||
|
// In a real implementation, you might want to make an HTTP head request
|
||||||
|
// or infer from URL extension
|
||||||
|
mediaType = inferMediaTypeFromURL(mediaURL)
|
||||||
|
|
||||||
|
// We don't have a hash yet, but we could calculate it if needed
|
||||||
|
// This would require downloading the file, which might be too heavy
|
||||||
|
mediaHash = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the appropriate event
|
||||||
|
var event *nostr.Event
|
||||||
|
var eventErr error
|
||||||
|
|
||||||
|
switch req.Kind {
|
||||||
|
case 1:
|
||||||
|
// Standard text note
|
||||||
|
// Create tags
|
||||||
|
var tags []nostr.Tag
|
||||||
|
for _, tag := range req.Hashtags {
|
||||||
|
tags = append(tags, nostr.Tag{"t", tag})
|
||||||
|
}
|
||||||
|
|
||||||
|
event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
|
||||||
|
case 20:
|
||||||
|
// Picture post
|
||||||
|
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
|
||||||
|
bot.Pubkey,
|
||||||
|
req.Title,
|
||||||
|
req.Content,
|
||||||
|
mediaURL,
|
||||||
|
mediaType,
|
||||||
|
mediaHash,
|
||||||
|
req.Alt, // Use the alt text if provided
|
||||||
|
req.Hashtags,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if eventErr != nil {
|
||||||
|
a.logger.Error("Failed to create event", zap.Error(eventErr))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure relay manager
|
||||||
|
for _, relay := range bot.Relays {
|
||||||
|
if relay.Write {
|
||||||
|
if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
|
||||||
|
a.logger.Warn("Failed to add relay",
|
||||||
|
zap.String("url", relay.URL),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
publishedRelays, err := a.botService.relayMgr.PublishEvent(ctx, event)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Failed to publish event", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Post published successfully",
|
||||||
|
"event_id": event.ID,
|
||||||
|
"relays": publishedRelays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to infer media type from URL
|
||||||
|
func inferMediaTypeFromURL(url string) string {
|
||||||
|
// Basic implementation - check file extension
|
||||||
|
lowerURL := strings.ToLower(url)
|
||||||
|
|
||||||
|
// Image types
|
||||||
|
if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") {
|
||||||
|
return "image/jpeg"
|
||||||
|
} else if strings.HasSuffix(lowerURL, ".png") {
|
||||||
|
return "image/png"
|
||||||
|
} else if strings.HasSuffix(lowerURL, ".gif") {
|
||||||
|
return "image/gif"
|
||||||
|
} else if strings.HasSuffix(lowerURL, ".webp") {
|
||||||
|
return "image/webp"
|
||||||
|
} else if strings.HasSuffix(lowerURL, ".svg") {
|
||||||
|
return "image/svg+xml"
|
||||||
|
} else if strings.HasSuffix(lowerURL, ".avif") {
|
||||||
|
return "image/avif"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to generic image type if we can't determine specifically
|
||||||
|
return "image/jpeg"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStats gets overall statistics
|
// getStats gets overall statistics
|
||||||
@ -582,4 +994,15 @@ func (a *API) getStats(c *gin.Context) {
|
|||||||
func (a *API) getBotStats(c *gin.Context) {
|
func (a *API) getBotStats(c *gin.Context) {
|
||||||
// This will be implemented when we add statistics
|
// This will be implemented when we add statistics
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract hashtags from tags
|
||||||
|
func extractHashtags(tags []nostr.Tag) []string {
|
||||||
|
var hashtags []string
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag[0] == "t" && len(tag) > 1 {
|
||||||
|
hashtags = append(hashtags, tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hashtags
|
||||||
}
|
}
|
@ -11,46 +11,48 @@ import (
|
|||||||
|
|
||||||
// Config holds all configuration for the application
|
// Config holds all configuration for the application
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// General configuration
|
// General configuration
|
||||||
AppName string `mapstructure:"app_name"`
|
AppName string `mapstructure:"app_name"`
|
||||||
ServerPort int `mapstructure:"server_port"`
|
ServerPort int `mapstructure:"server_port"`
|
||||||
LogLevel string `mapstructure:"log_level"`
|
LogLevel string `mapstructure:"log_level"`
|
||||||
|
|
||||||
// Bot configuration
|
// Bot configuration
|
||||||
Bot struct {
|
Bot struct {
|
||||||
KeysFile string `mapstructure:"keys_file"`
|
KeysFile string `mapstructure:"keys_file"`
|
||||||
ContentDir string `mapstructure:"content_dir"`
|
ContentDir string `mapstructure:"content_dir"`
|
||||||
ArchiveDir string `mapstructure:"archive_dir"`
|
ArchiveDir string `mapstructure:"archive_dir"`
|
||||||
DefaultInterval int `mapstructure:"default_interval"` // in minutes
|
DefaultInterval int `mapstructure:"default_interval"` // in minutes
|
||||||
} `mapstructure:"bot"`
|
} `mapstructure:"bot"`
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
DB struct {
|
DB struct {
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
} `mapstructure:"db"`
|
} `mapstructure:"db"`
|
||||||
|
|
||||||
// Media services configuration
|
// Media services configuration
|
||||||
Media struct {
|
Media struct {
|
||||||
DefaultService string `mapstructure:"default_service"` // "nip94" or "blossom"
|
DefaultService string `mapstructure:"default_service"` // "nip94" or "blossom"
|
||||||
|
|
||||||
// NIP-94 configuration
|
// NIP-94 configuration
|
||||||
NIP94 struct {
|
NIP94 struct {
|
||||||
ServerURL string `mapstructure:"server_url"`
|
ServerURL string `mapstructure:"server_url"`
|
||||||
RequireAuth bool `mapstructure:"require_auth"`
|
RequireAuth bool `mapstructure:"require_auth"`
|
||||||
} `mapstructure:"nip94"`
|
} `mapstructure:"nip94"`
|
||||||
|
|
||||||
// Blossom configuration
|
// Blossom configuration
|
||||||
Blossom struct {
|
Blossom struct {
|
||||||
ServerURL string `mapstructure:"server_url"`
|
ServerURL string `mapstructure:"server_url"`
|
||||||
} `mapstructure:"blossom"`
|
} `mapstructure:"blossom"`
|
||||||
} `mapstructure:"media"`
|
} `mapstructure:"media"`
|
||||||
|
|
||||||
// Default relays
|
// Default relays
|
||||||
Relays []struct {
|
Relays []struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Read bool `mapstructure:"read"`
|
Read bool `mapstructure:"read"`
|
||||||
Write bool `mapstructure:"write"`
|
Write bool `mapstructure:"write"`
|
||||||
} `mapstructure:"relays"`
|
} `mapstructure:"relays"`
|
||||||
|
|
||||||
|
AllowedNpub string `mapstructure:"allowed_npub"` // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads the configuration from file or environment variables
|
// LoadConfig loads the configuration from file or environment variables
|
||||||
@ -59,7 +61,7 @@ func LoadConfig(configPath string) (*Config, error) {
|
|||||||
|
|
||||||
// Set defaults
|
// Set defaults
|
||||||
v.SetDefault("app_name", "Nostr Poster")
|
v.SetDefault("app_name", "Nostr Poster")
|
||||||
v.SetDefault("server_port", 8080)
|
v.SetDefault("server_port", 8765)
|
||||||
v.SetDefault("log_level", "info")
|
v.SetDefault("log_level", "info")
|
||||||
|
|
||||||
v.SetDefault("bot.keys_file", "keys.json")
|
v.SetDefault("bot.keys_file", "keys.json")
|
||||||
@ -74,11 +76,11 @@ func LoadConfig(configPath string) (*Config, error) {
|
|||||||
|
|
||||||
// Default relays
|
// Default relays
|
||||||
v.SetDefault("relays", []map[string]interface{}{
|
v.SetDefault("relays", []map[string]interface{}{
|
||||||
{"url": "wss://relay.damus.io", "read": true, "write": true},
|
{"url": "wss://freelay.sovbit.host", "read": true, "write": true},
|
||||||
{"url": "wss://nostr.mutinywallet.com", "read": true, "write": true},
|
|
||||||
{"url": "wss://relay.nostr.band", "read": true, "write": true},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
v.SetDefault("allowed_npub", "")
|
||||||
|
|
||||||
// Setup config file search
|
// Setup config file search
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
// Use config file from the flag
|
// Use config file from the flag
|
||||||
@ -157,6 +159,7 @@ func (c *Config) Save(configPath string) error {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"relays": c.Relays,
|
"relays": c.Relays,
|
||||||
|
"allowed_npub": c.AllowedNpub,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -3,7 +3,6 @@ package crypto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -13,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"github.com/nbd-wtf/go-nostr/nip19" // Added import for proper NSEC decoding
|
||||||
"golang.org/x/crypto/nacl/secretbox"
|
"golang.org/x/crypto/nacl/secretbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -104,6 +104,15 @@ func (ks *KeyStore) GenerateKey() (pubkey, privkey string, err error) {
|
|||||||
|
|
||||||
// AddKey imports an existing private key
|
// AddKey imports an existing private key
|
||||||
func (ks *KeyStore) AddKey(pubkey, privkey string) error {
|
func (ks *KeyStore) AddKey(pubkey, privkey string) error {
|
||||||
|
// Check if the private key is in NSEC format and decode it if needed
|
||||||
|
if len(privkey) > 4 && privkey[:4] == "nsec" {
|
||||||
|
var err error
|
||||||
|
privkey, err = decodePrivateKey(privkey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode nsec key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the key pair
|
// Validate the key pair
|
||||||
derivedPub, err := nostr.GetPublicKey(privkey)
|
derivedPub, err := nostr.GetPublicKey(privkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -196,6 +205,11 @@ func (ks *KeyStore) ListKeys() []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecodeNsecKey decodes an nsec key to hex format
|
||||||
|
func (ks *KeyStore) DecodeNsecKey(nsecKey string) (string, error) {
|
||||||
|
return decodePrivateKey(nsecKey)
|
||||||
|
}
|
||||||
|
|
||||||
// encryptKey encrypts a private key using the store's password
|
// encryptKey encrypts a private key using the store's password
|
||||||
func (ks *KeyStore) encryptKey(privkey string) (string, error) {
|
func (ks *KeyStore) encryptKey(privkey string) (string, error) {
|
||||||
// Generate a random nonce
|
// Generate a random nonce
|
||||||
@ -286,22 +300,28 @@ func (ks *KeyStore) ChangePassword(newPassword string) error {
|
|||||||
return ks.saveKeys()
|
return ks.saveKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodePrivateKey decodes an nsec private key
|
// decodePrivateKey decodes an nsec private key using proper NIP-19 format
|
||||||
func decodePrivateKey(nsecKey string) (string, error) {
|
func decodePrivateKey(nsecKey string) (string, error) {
|
||||||
if len(nsecKey) < 4 || nsecKey[:4] != "nsec" {
|
if len(nsecKey) < 4 || nsecKey[:4] != "nsec" {
|
||||||
return "", fmt.Errorf("invalid nsec key")
|
return "", fmt.Errorf("invalid nsec key")
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(nsecKey[4:])
|
// Use the proper NIP-19 decoder
|
||||||
if err != nil {
|
prefix, data, err := nip19.Decode(nsecKey)
|
||||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
if err != nil {
|
||||||
}
|
return "", fmt.Errorf("failed to decode nsec key: %w", err)
|
||||||
|
}
|
||||||
// Remove the version byte and checksum
|
|
||||||
if len(data) < 2 {
|
// Verify that we got the right type of key
|
||||||
return "", fmt.Errorf("invalid nsec data length")
|
if prefix != "nsec" {
|
||||||
}
|
return "", fmt.Errorf("expected nsec prefix, got %s", prefix)
|
||||||
|
}
|
||||||
privkeyBytes := data[1 : len(data)-4]
|
|
||||||
return hex.EncodeToString(privkeyBytes), nil
|
// For nsec keys, data should be a hex string of the private key
|
||||||
|
hexKey, ok := data.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid nsec data format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexKey, nil
|
||||||
}
|
}
|
@ -2,48 +2,57 @@
|
|||||||
package blossom
|
package blossom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BlobDescriptor represents metadata about a blob stored with Blossom
|
// BlobDescriptor represents metadata about a blob stored with Blossom
|
||||||
type BlobDescriptor struct {
|
type BlobDescriptor struct {
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
Height int `json:"height,omitempty"`
|
Height int `json:"height,omitempty"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
Alt string `json:"alt,omitempty"`
|
Alt string `json:"alt,omitempty"`
|
||||||
Caption string `json:"caption,omitempty"`
|
Caption string `json:"caption,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResponse represents the response from a Blossom upload
|
// BlossomResponse represents the response from a Blossom upload
|
||||||
type UploadResponse struct {
|
type BlossomResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Blob BlobDescriptor `json:"blob"`
|
URL string `json:"url"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Uploaded int64 `json:"uploaded"`
|
||||||
|
BlurHash string `json:"blurhash"`
|
||||||
|
Dim string `json:"dim"`
|
||||||
|
PaymentRequest string `json:"payment_request"`
|
||||||
|
Visibility int `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uploader implements the media upload functionality for Blossom
|
// Uploader implements the media upload functionality for Blossom
|
||||||
type Uploader struct {
|
type Uploader struct {
|
||||||
serverURL string
|
serverURL string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
// Function to get a signed auth header (for Blossom authentication)
|
// Function to get a signed auth header (for Blossom authentication)
|
||||||
getAuthHeader func(url, method string) (string, error)
|
getAuthHeader func(url, method string) (string, error)
|
||||||
}
|
}
|
||||||
@ -63,242 +72,314 @@ func NewUploader(
|
|||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Uploader{
|
return &Uploader{
|
||||||
serverURL: serverURL,
|
serverURL: serverURL,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
getAuthHeader: getAuthHeader,
|
getAuthHeader: getAuthHeader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadFile uploads a file to a Blossom server
|
// getEnhancedContentType tries multiple methods to get the most accurate content type
|
||||||
|
func getEnhancedContentType(filePath string) (string, error) {
|
||||||
|
// Get extension to help with MIME type detection
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
// Try standard content type detection first
|
||||||
|
contentType, err := utils.GetFileContentType(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to determine content type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with more specific MIME types for certain file extensions
|
||||||
|
// These are common file extensions that sometimes get generic MIME types
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg", nil
|
||||||
|
case ".png":
|
||||||
|
return "image/png", nil
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif", nil
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp", nil
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml", nil
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4", nil
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm", nil
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime", nil
|
||||||
|
case ".mp3":
|
||||||
|
return "audio/mpeg", nil
|
||||||
|
case ".wav":
|
||||||
|
return "audio/wav", nil
|
||||||
|
case ".pdf":
|
||||||
|
return "application/pdf", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got a generic type like "application/octet-stream", try to be more specific
|
||||||
|
if contentType == "application/octet-stream" || contentType == "text/plain" {
|
||||||
|
// Add more specific mappings for binary files that might be detected as octet-stream
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg", nil
|
||||||
|
case ".png":
|
||||||
|
return "image/png", nil
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile uploads a file to a Blossom server using raw binary data in the request body
|
||||||
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
func (u *Uploader) UploadFile(filePath string, caption string, altText string) (string, string, error) {
|
||||||
// Open the file
|
// Log information about the upload
|
||||||
file, err := os.Open(filePath)
|
u.logger.Info("Uploading file to Blossom server",
|
||||||
if err != nil {
|
zap.String("filePath", filePath),
|
||||||
return "", "", fmt.Errorf("failed to open file: %w", err)
|
zap.String("serverURL", u.serverURL))
|
||||||
}
|
|
||||||
defer file.Close()
|
// Open the file
|
||||||
|
file, err := os.Open(filePath)
|
||||||
// Get file info
|
if err != nil {
|
||||||
fileInfo, err := file.Stat()
|
return "", "", fmt.Errorf("failed to open file: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
return "", "", fmt.Errorf("failed to get file info: %w", err)
|
defer file.Close()
|
||||||
}
|
|
||||||
|
// Get file info
|
||||||
// Calculate file hash
|
fileInfo, err := file.Stat()
|
||||||
hasher := sha256.New()
|
if err != nil {
|
||||||
if _, err := io.Copy(hasher, file); err != nil {
|
return "", "", fmt.Errorf("failed to get file info: %w", err)
|
||||||
return "", "", fmt.Errorf("failed to calculate file hash: %w", err)
|
}
|
||||||
}
|
fileSize := fileInfo.Size()
|
||||||
|
|
||||||
// Reset file pointer
|
// Calculate file hash before reading the file for the request body
|
||||||
if _, err := file.Seek(0, 0); err != nil {
|
hasher := sha256.New()
|
||||||
return "", "", fmt.Errorf("failed to reset file: %w", err)
|
if _, err := io.Copy(hasher, file); err != nil {
|
||||||
}
|
return "", "", fmt.Errorf("failed to calculate file hash: %w", err)
|
||||||
|
}
|
||||||
// Get hash as hex
|
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
fileHash := hex.EncodeToString(hasher.Sum(nil))
|
|
||||||
|
// Reset file pointer to the beginning
|
||||||
// Get content type
|
if _, err := file.Seek(0, 0); err != nil {
|
||||||
contentType, err := utils.GetFileContentType(filePath)
|
return "", "", fmt.Errorf("failed to reset file: %w", err)
|
||||||
if err != nil {
|
}
|
||||||
return "", "", fmt.Errorf("failed to determine content type: %w", err)
|
|
||||||
}
|
// Get enhanced content type
|
||||||
|
contentType, err := getEnhancedContentType(filePath)
|
||||||
// Create a buffer for the multipart form
|
if err != nil {
|
||||||
var requestBody bytes.Buffer
|
return "", "", fmt.Errorf("failed to determine content type: %w", err)
|
||||||
writer := multipart.NewWriter(&requestBody)
|
}
|
||||||
|
|
||||||
// Add the file
|
u.logger.Info("File details",
|
||||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
zap.String("filename", filepath.Base(filePath)),
|
||||||
if err != nil {
|
zap.String("contentType", contentType),
|
||||||
return "", "", fmt.Errorf("failed to create form file: %w", err)
|
zap.Int64("size", fileSize),
|
||||||
}
|
zap.String("hash", fileHash))
|
||||||
|
|
||||||
if _, err := io.Copy(part, file); err != nil {
|
// Create the request with the file as the raw body
|
||||||
return "", "", fmt.Errorf("failed to copy file to form: %w", err)
|
// This follows BUD-02 which states the endpoint must accept binary data in the body
|
||||||
}
|
req, err := http.NewRequest("PUT", u.serverURL, file)
|
||||||
|
if err != nil {
|
||||||
// Add caption if provided
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
if caption != "" {
|
}
|
||||||
if err := writer.WriteField("caption", caption); err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to add caption: %w", err)
|
// Set headers
|
||||||
}
|
req.Header.Set("Content-Type", contentType)
|
||||||
}
|
req.Header.Set("Content-Length", fmt.Sprintf("%d", fileSize))
|
||||||
|
|
||||||
// Add alt text if provided
|
// Add additional headers that might help the server
|
||||||
if altText != "" {
|
req.Header.Set("X-Content-Type", contentType)
|
||||||
if err := writer.WriteField("alt", altText); err != nil {
|
req.Header.Set("X-File-Name", filepath.Base(filePath))
|
||||||
return "", "", fmt.Errorf("failed to add alt text: %w", err)
|
req.Header.Set("X-File-Size", fmt.Sprintf("%d", fileSize))
|
||||||
}
|
req.Header.Set("X-File-Hash", fileHash)
|
||||||
}
|
|
||||||
|
// If we have caption or alt text, add them as headers
|
||||||
// Add file size information
|
if caption != "" {
|
||||||
if err := writer.WriteField("size", fmt.Sprintf("%d", fileInfo.Size())); err != nil {
|
req.Header.Set("X-Caption", caption)
|
||||||
return "", "", fmt.Errorf("failed to add file size: %w", err)
|
}
|
||||||
}
|
if altText != "" {
|
||||||
|
req.Header.Set("X-Alt", altText)
|
||||||
// Add content type
|
}
|
||||||
if err := writer.WriteField("content_type", contentType); err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to add content type: %w", err)
|
// Add authorization header if available
|
||||||
}
|
if u.getAuthHeader != nil {
|
||||||
|
authHeader, err := u.getAuthHeader(u.serverURL, "PUT")
|
||||||
// Add file hash (for integrity verification)
|
if err != nil {
|
||||||
if err := writer.WriteField("hash", fileHash); err != nil {
|
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
||||||
return "", "", fmt.Errorf("failed to add file hash: %w", err)
|
}
|
||||||
}
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
}
|
||||||
// Close the writer
|
|
||||||
if err := writer.Close(); err != nil {
|
// Create HTTP client with timeout
|
||||||
return "", "", fmt.Errorf("failed to close multipart writer: %w", err)
|
client := &http.Client{
|
||||||
}
|
Timeout: 2 * time.Minute,
|
||||||
|
}
|
||||||
// Create the upload URL
|
|
||||||
uploadURL := fmt.Sprintf("%s/upload", u.serverURL)
|
// Send the request
|
||||||
|
u.logger.Info("Sending raw binary request to Blossom server",
|
||||||
// Create the request
|
zap.String("contentType", contentType),
|
||||||
req, err := http.NewRequest("PUT", uploadURL, &requestBody)
|
zap.Int64("contentLength", fileSize))
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
resp, err := client.Do(req)
|
||||||
}
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to send request: %w", err)
|
||||||
// Set content type
|
}
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Add authorization header if available
|
// Read the response body for logging
|
||||||
if u.getAuthHeader != nil {
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
authHeader, err := u.getAuthHeader(uploadURL, "PUT")
|
bodyStr := string(bodyBytes)
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to create auth header: %w", err)
|
// Log response details
|
||||||
}
|
u.logger.Info("Received response from server",
|
||||||
|
zap.Int("statusCode", resp.StatusCode),
|
||||||
req.Header.Set("Authorization", authHeader)
|
zap.String("body", bodyStr))
|
||||||
}
|
|
||||||
|
// Check response status - accept 200, 201, and 202 as success
|
||||||
// Create HTTP client with timeout
|
if resp.StatusCode != http.StatusOK &&
|
||||||
client := &http.Client{
|
resp.StatusCode != http.StatusCreated &&
|
||||||
Timeout: 2 * time.Minute,
|
resp.StatusCode != http.StatusAccepted {
|
||||||
}
|
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, bodyStr)
|
||||||
|
}
|
||||||
// Send the request
|
|
||||||
resp, err := client.Do(req)
|
// Parse response
|
||||||
if err != nil {
|
var blossomResp BlossomResponse
|
||||||
return "", "", fmt.Errorf("failed to send request: %w", err)
|
if err := json.Unmarshal(bodyBytes, &blossomResp); err != nil {
|
||||||
}
|
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
defer resp.Body.Close()
|
}
|
||||||
|
|
||||||
// Check response status
|
// Check for success
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
if blossomResp.Status != "success" {
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
return "", "", fmt.Errorf("upload failed: %s", blossomResp.Message)
|
||||||
return "", "", fmt.Errorf("server returned non-success status: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
}
|
||||||
}
|
|
||||||
|
// Log the successful response
|
||||||
// Parse response
|
u.logger.Info("Upload successful",
|
||||||
var uploadResp UploadResponse
|
zap.String("url", blossomResp.URL),
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
|
zap.String("hash", blossomResp.SHA256),
|
||||||
return "", "", fmt.Errorf("failed to parse response: %w", err)
|
zap.String("dimensions", blossomResp.Dim),
|
||||||
}
|
zap.Int64("size", blossomResp.Size))
|
||||||
|
|
||||||
// Check for success
|
// Use the URL directly from the response
|
||||||
if uploadResp.Status != "success" {
|
return blossomResp.URL, blossomResp.SHA256, nil
|
||||||
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
|
// DeleteFile deletes a file from the Blossom server
|
||||||
func (u *Uploader) DeleteFile(fileHash string) error {
|
func (u *Uploader) DeleteFile(fileHash string) error {
|
||||||
// Create the delete URL
|
// Create the delete URL
|
||||||
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
deleteURL := fmt.Sprintf("%s/%s", u.serverURL, fileHash)
|
||||||
|
|
||||||
// Create the request
|
// Create the request
|
||||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create delete request: %w", err)
|
return fmt.Errorf("failed to create delete request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add authorization header if available
|
// Add authorization header if available
|
||||||
if u.getAuthHeader != nil {
|
if u.getAuthHeader != nil {
|
||||||
authHeader, err := u.getAuthHeader(deleteURL, "DELETE")
|
authHeader, err := u.getAuthHeader(deleteURL, "DELETE")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create auth header: %w", err)
|
return fmt.Errorf("failed to create auth header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", authHeader)
|
req.Header.Set("Authorization", authHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send delete request: %w", err)
|
return fmt.Errorf("failed to send delete request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Check response status
|
// Check response status
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
return fmt.Errorf("server returned non-OK status for delete: %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateBlossomAuthHeader creates a Blossom authentication header
|
// CreateBlossomAuthHeader creates a Blossom authentication header
|
||||||
// BUD-01 requires a kind 24242 authorization event
|
func CreateBlossomAuthHeader(fullURL, method string, privkey string) (string, error) {
|
||||||
func CreateBlossomAuthHeader(url, method string, privkey string) (string, error) {
|
// Parse the URL to extract the path for the endpoint
|
||||||
// Create the event
|
parsed, err := url.Parse(fullURL)
|
||||||
tags := []nostr.Tag{
|
var endpoint string
|
||||||
{"u", url},
|
if err == nil && parsed.Path != "" {
|
||||||
{"method", method},
|
// Use the full path as the endpoint
|
||||||
|
endpoint = parsed.Path
|
||||||
|
// Remove leading slash if present
|
||||||
|
endpoint = strings.TrimPrefix(endpoint, "/")
|
||||||
|
} else {
|
||||||
|
// Fallback to the full URL if parsing fails
|
||||||
|
endpoint = fullURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set an expiration 5 minutes in the future
|
||||||
|
expiration := time.Now().Add(5 * time.Minute).Unix()
|
||||||
|
|
||||||
|
// Set the operation type for BUD-02 compliance
|
||||||
|
operation := "upload"
|
||||||
|
if method == "DELETE" {
|
||||||
|
operation = "delete"
|
||||||
|
} else if strings.Contains(endpoint, "list") {
|
||||||
|
operation = "list"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the tags array with required Blossom tags
|
||||||
|
tags := []nostr.Tag{
|
||||||
|
{"t", operation}, // Required tag for Blossom auth
|
||||||
|
{"u", endpoint}, // URL endpoint
|
||||||
|
{"method", method},
|
||||||
|
{"expiration", fmt.Sprintf("%d", expiration)},
|
||||||
|
}
|
||||||
|
|
||||||
// Create the auth event
|
// Create the auth event
|
||||||
authEvent := nostr.Event{
|
authEvent := nostr.Event{
|
||||||
Kind: 24242, // Blossom Authorization event
|
Kind: 24242,
|
||||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Content: "", // Empty content for auth events
|
Content: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the public key
|
// Get the public key from the private key
|
||||||
pubkey, err := nostr.GetPublicKey(privkey)
|
pubkey, err := nostr.GetPublicKey(privkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authEvent.PubKey = pubkey
|
authEvent.PubKey = pubkey
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
err = authEvent.Sign(privkey)
|
if err := authEvent.Sign(privkey); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to sign auth event: %w", err)
|
return "", fmt.Errorf("failed to sign auth event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize the event
|
// Serialize the event
|
||||||
eventJSON, err := json.Marshal(authEvent)
|
eventJSON, err := json.Marshal(authEvent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to serialize auth event: %w", err)
|
return "", fmt.Errorf("failed to serialize auth event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode as base64
|
// Encode as base64
|
||||||
encodedEvent := base64.StdEncoding.EncodeToString(eventJSON)
|
encodedEvent := base64.StdEncoding.EncodeToString(eventJSON)
|
||||||
|
|
||||||
// Return the authorization header
|
// Return the authorization header
|
||||||
return "Nostr " + encodedEvent, nil
|
return "Nostr " + encodedEvent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCustomURL creates a new uploader instance with the specified custom URL.
|
||||||
|
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
||||||
|
return &Uploader{
|
||||||
|
serverURL: customURL,
|
||||||
|
logger: u.logger,
|
||||||
|
getAuthHeader: u.getAuthHeader,
|
||||||
|
}
|
||||||
}
|
}
|
@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
||||||
|
"git.sovbit.dev/Enki/nostr-poster/internal/scheduler"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -470,4 +471,16 @@ func CreateNIP98AuthHeader(url, method string, payload []byte, privkey string) (
|
|||||||
|
|
||||||
// Return the authorization header
|
// Return the authorization header
|
||||||
return "Nostr " + encodedEvent, nil
|
return "Nostr " + encodedEvent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCustomURL creates a new uploader instance with the specified custom URL
|
||||||
|
func (u *Uploader) WithCustomURL(customURL string) scheduler.MediaUploader {
|
||||||
|
// Create a new uploader with the same configuration but a different URL
|
||||||
|
return &Uploader{
|
||||||
|
serverURL: customURL,
|
||||||
|
downloadURL: u.downloadURL,
|
||||||
|
supportedTypes: u.supportedTypes,
|
||||||
|
logger: u.logger,
|
||||||
|
getAuthHeader: u.getAuthHeader,
|
||||||
|
}
|
||||||
}
|
}
|
@ -9,7 +9,7 @@ import (
|
|||||||
type Bot struct {
|
type Bot struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ID int64 `db:"id" json:"id"`
|
||||||
Pubkey string `db:"pubkey" json:"pubkey"`
|
Pubkey string `db:"pubkey" json:"pubkey"`
|
||||||
EncryptedPrivkey string `db:"encrypted_privkey" json:"-"` // Encrypted, never sent to client
|
EncryptedPrivkey string `db:"encrypted_privkey" json:"encrypted_privkey,omitempty"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
DisplayName string `db:"display_name" json:"display_name"`
|
DisplayName string `db:"display_name" json:"display_name"`
|
||||||
Bio string `db:"bio" json:"bio"`
|
Bio string `db:"bio" json:"bio"`
|
||||||
|
@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/nbd-wtf/go-nostr"
|
"github.com/nbd-wtf/go-nostr"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
||||||
@ -136,7 +138,7 @@ func (em *EventManager) CreateAndSignMediaEvent(
|
|||||||
return &ev, nil
|
return &ev, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAndSignPictureEvent creates and signs a kind 20 picture event (NIP-68)
|
// Updated CreateAndSignPictureEvent to properly format events according to NIP-68
|
||||||
func (em *EventManager) CreateAndSignPictureEvent(
|
func (em *EventManager) CreateAndSignPictureEvent(
|
||||||
pubkey string,
|
pubkey string,
|
||||||
title string,
|
title string,
|
||||||
@ -147,31 +149,73 @@ func (em *EventManager) CreateAndSignPictureEvent(
|
|||||||
altText string,
|
altText string,
|
||||||
hashtags []string,
|
hashtags []string,
|
||||||
) (*nostr.Event, error) {
|
) (*nostr.Event, error) {
|
||||||
// Create the imeta tag for the media
|
// For picture events (kind 20), we need to follow NIP-68 format
|
||||||
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
|
|
||||||
|
|
||||||
// Add hash if available
|
// Start with the required title tag
|
||||||
if mediaHash != "" {
|
|
||||||
imeta = append(imeta, "x "+mediaHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add alt text if available
|
|
||||||
if altText != "" {
|
|
||||||
imeta = append(imeta, "alt "+altText)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tags
|
|
||||||
tags := []nostr.Tag{
|
tags := []nostr.Tag{
|
||||||
{"title", title},
|
{"title", title},
|
||||||
imeta,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add media type tag
|
// Process media URLs from the description
|
||||||
tags = append(tags, nostr.Tag{"m", mediaType})
|
var contentWithoutURLs string
|
||||||
|
|
||||||
// Add media hash tag
|
// If no explicit mediaURL is provided, try to extract from description
|
||||||
if mediaHash != "" {
|
if mediaURL == "" {
|
||||||
tags = append(tags, nostr.Tag{"x", mediaHash})
|
// Simple regex to extract URLs
|
||||||
|
re := regexp.MustCompile(`https?://[^\s]+`)
|
||||||
|
matches := re.FindAllString(description, -1)
|
||||||
|
|
||||||
|
if len(matches) > 0 {
|
||||||
|
mediaURL = matches[0]
|
||||||
|
|
||||||
|
// Remove the URL from the description for cleaner content
|
||||||
|
contentWithoutURLs = re.ReplaceAllString(description, "")
|
||||||
|
contentWithoutURLs = strings.TrimSpace(contentWithoutURLs)
|
||||||
|
} else {
|
||||||
|
// If no URL in description either, we'll just use the description as is
|
||||||
|
contentWithoutURLs = description
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We have an explicit mediaURL, make sure it's not in the description
|
||||||
|
contentWithoutURLs = strings.ReplaceAll(description, mediaURL, "")
|
||||||
|
contentWithoutURLs = strings.TrimSpace(contentWithoutURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the imeta tag according to NIP-92 format
|
||||||
|
if mediaURL != "" {
|
||||||
|
// imeta tag needs to be an array of strings
|
||||||
|
imetaTag := []string{"imeta"}
|
||||||
|
|
||||||
|
// Add URL (must be space-delimited key/value)
|
||||||
|
imetaTag = append(imetaTag, "url "+mediaURL)
|
||||||
|
|
||||||
|
// Add media type (must be space-delimited key/value)
|
||||||
|
if mediaType != "" {
|
||||||
|
imetaTag = append(imetaTag, "m "+mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hash if available (must be space-delimited key/value)
|
||||||
|
if mediaHash != "" {
|
||||||
|
imetaTag = append(imetaTag, "x "+mediaHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add alt text if available (must be space-delimited key/value)
|
||||||
|
if altText != "" {
|
||||||
|
imetaTag = append(imetaTag, "alt "+altText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to tags - nostr.Tag is just a []string so this works
|
||||||
|
tags = append(tags, imetaTag)
|
||||||
|
|
||||||
|
// Add the media type as a separate 'm' tag for filtering (required by NIP-68)
|
||||||
|
if mediaType != "" {
|
||||||
|
tags = append(tags, nostr.Tag{"m", mediaType})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the hash as a separate 'x' tag for querying (required by NIP-68)
|
||||||
|
if mediaHash != "" {
|
||||||
|
tags = append(tags, nostr.Tag{"x", mediaHash})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add hashtags
|
// Add hashtags
|
||||||
@ -184,12 +228,12 @@ func (em *EventManager) CreateAndSignPictureEvent(
|
|||||||
Kind: 20, // NIP-68 Picture Event
|
Kind: 20, // NIP-68 Picture Event
|
||||||
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
CreatedAt: nostr.Timestamp(time.Now().Unix()),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Content: description,
|
Content: contentWithoutURLs, // Use the cleaned content without URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
if err := em.SignEvent(&ev, pubkey); err != nil {
|
if err := em.SignEvent(&ev, pubkey); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to sign picture event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ev, nil
|
return &ev, nil
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
||||||
@ -18,8 +19,9 @@ import (
|
|||||||
|
|
||||||
// MediaUploader defines the interface for uploading media
|
// MediaUploader defines the interface for uploading media
|
||||||
type MediaUploader interface {
|
type MediaUploader interface {
|
||||||
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
||||||
DeleteFile(fileHash string) error
|
DeleteFile(fileHash string) error
|
||||||
|
WithCustomURL(customURL string) MediaUploader
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostPublisher defines the interface for publishing posts
|
// PostPublisher defines the interface for publishing posts
|
||||||
@ -334,6 +336,19 @@ func (s *Scheduler) UpdateBotSchedule(bot *models.Bot) error {
|
|||||||
return s.ScheduleBot(bot)
|
return s.ScheduleBot(bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNIP94Uploader returns the NIP-94 uploader
|
||||||
|
func (s *Scheduler) GetNIP94Uploader() MediaUploader {
|
||||||
|
return s.nip94Uploader
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlossomUploader returns the Blossom uploader
|
||||||
|
func (s *Scheduler) GetBlossomUploader() MediaUploader {
|
||||||
|
return s.blossomUploader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) GetContentDir() string {
|
||||||
|
return s.contentDir
|
||||||
|
}
|
||||||
// RunNow triggers an immediate post for a bot
|
// RunNow triggers an immediate post for a bot
|
||||||
func (s *Scheduler) RunNow(botID int64) error {
|
func (s *Scheduler) RunNow(botID int64) error {
|
||||||
// Load the bot with its configurations
|
// Load the bot with its configurations
|
||||||
@ -380,4 +395,4 @@ func (s *Scheduler) RunNow(botID int64) error {
|
|||||||
entry.Job.Run()
|
entry.Job.Run()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -142,29 +143,71 @@ func CalculateFileHash(filePath string) (string, error) {
|
|||||||
|
|
||||||
// GetFileContentType tries to determine the content type of a file
|
// GetFileContentType tries to determine the content type of a file
|
||||||
func GetFileContentType(filePath string) (string, error) {
|
func GetFileContentType(filePath string) (string, error) {
|
||||||
// Open the file
|
// Open the file
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read the first 512 bytes to determine the content type
|
// Read the first 512 bytes to determine the content type
|
||||||
buffer := make([]byte, 512)
|
buffer := make([]byte, 512)
|
||||||
_, err = file.Read(buffer)
|
bytesRead, err := file.Read(buffer)
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we read less than 512 bytes, resize the buffer
|
||||||
|
if bytesRead < 512 {
|
||||||
|
buffer = buffer[:bytesRead]
|
||||||
|
}
|
||||||
|
|
||||||
// Reset the file pointer
|
// Reset the file pointer
|
||||||
_, err = file.Seek(0, 0)
|
_, err = file.Seek(0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect the content type
|
// Detect the content type
|
||||||
contentType := http.DetectContentType(buffer)
|
contentType := http.DetectContentType(buffer)
|
||||||
return contentType, nil
|
|
||||||
|
// If the detection gave us a generic type, try to be more specific using the extension
|
||||||
|
if contentType == "application/octet-stream" || contentType == "text/plain" {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg", nil
|
||||||
|
case ".png":
|
||||||
|
return "image/png", nil
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif", nil
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp", nil
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4", nil
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime", nil
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm", nil
|
||||||
|
case ".mp3":
|
||||||
|
return "audio/mpeg", nil
|
||||||
|
case ".wav":
|
||||||
|
return "audio/wav", nil
|
||||||
|
case ".pdf":
|
||||||
|
return "application/pdf", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for WebP signature, which is sometimes not detected correctly
|
||||||
|
if contentType == "application/octet-stream" || contentType == "image/webp" {
|
||||||
|
if bytesRead > 12 {
|
||||||
|
if bytes.HasPrefix(buffer, []byte("RIFF")) && bytes.Contains(buffer[8:12], []byte("WEBP")) {
|
||||||
|
return "image/webp", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSupportedImageExtensions returns a list of supported image extensions
|
// GetSupportedImageExtensions returns a list of supported image extensions
|
||||||
|
17
keys.json
17
keys.json
@ -1,5 +1,20 @@
|
|||||||
{
|
{
|
||||||
|
"05480a388b72ca9c3a3b449f2d34d5088c243fcdf67f1dd836fcd0c0fa9fb369": "30e9655fab1f7898c30544770e148adb45c49af995bf0556c48f80526b94054a96529032c420f065232952e9e5ee35ecd2e4b9dfd3d769d284320445a9b77b1459129a138e023b7d01a1896f374f8ad9f0394be2be5a6a0150404945397c62ef5b86bf02ee84deba",
|
||||||
"10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4",
|
"10433dd61341df66c74b4f4557e3b812610d173dd76a59d6f7a127e0baeb0fa3": "27a34793b760f928f64d7c47232f153163ede2296d1a7cfe1d64ee57b0fc18b213a9638a14c30ee12f77a9adbdd7cf82da676c9d7307801d5c5e17c5cb879e0ce8ebf973d4ea19c2af604cc6994d054e4b94daff7406b9c8400b30fd567f5a14cb0a1a8e59ec67f4",
|
||||||
|
"1b9ee4ecb3587bc6091d9c284bca486d4d42d9bbcb52a8c5c017b7ab891d5a1e": "b2fed85f916572965c7e0b096b4c35ca1e495f8d176fcea1e092a50b28b9d187dac7247110828f777c51dc6ab375a604f9f42913e46867097511cd6374c8bc089f536ebc8d2d14245ed80c95f5a0c3a7817e4982d1989a9164dba39b90b86ed1e823d404e46036fb",
|
||||||
|
"220b94f311cb0d2ae9550e0c3e9a201971da9a81fc95f806727ee1224860ffca": "4317b62cfd3693b9352051a64fe1f9af25d0d9f95524c984f6521830c8b5f99522abcc64afe0c7898da96f5d9d087e9606426c1dc99b77eacee68c99953fc1d5c4b9d12d4d840c727e218ef1f7a2b50402240fca603a37a9404bf4ac98ef9f8ec144ad4e25b3c42a",
|
||||||
"2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504",
|
"2a31af0ea696d101fa253ec87b94f444d5b55be183ff5bd13f91c7bcc1300d39": "28d2e88054a40ac4a919983ec1da59176b211f7211a5e699532b0d8d8aeb97a2a14cdd494b307605e93796883dea63963c1e18cf99a74827b8d79ae10909e8511b0c671f9d2d863bda77df0bbac7de5d1a207872842f0ea7ffdf55930defc36a7eee8f81c5737504",
|
||||||
"67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a"
|
"407057c4bb1e2bdd0ee73720f453c61cd55b94abd73901de30e34dc15a605d14": "3133ab7d2ddc20730b7cc7246616c1a2d2dcf2219a93f98c7085113748f6b8467ea1b87e5e28ee96cc4cf6963acdafac894cc17574cf75300e041d973f4be4a2e50d94b7ba13e92f57e04e1d52ec5a03dd896ac809735722fac4954d47cd3b5fa8329c567c620195",
|
||||||
|
"5159fa4eedc1e31ba1fdcb0abcc9a5f36bb671eeeb36a2e1c1f9d7cb03374870": "555b7d94d8f59e8d5347677219027edcd04d1a51ffe30b1ccc50c2a39915662b9c66ce52d3ddc963a5a3ddc113a1e276ac2e6b51d884ef1a5e0bb61fc3f11718655174a35475c06c72b49ff7c2428f3fd30fc25ee9a77c33aba667cc26c985930b26ba9f70b5f1d6",
|
||||||
|
"67e92e467cf0e2d8f2075a0e7707f9ee56d00e4dc4851d9ba66ca9a9f5ad188a": "2d2ad8636edf99f55c90c93808f1609c16ad7a29f6810a5c733fc6590d2c37e296156cea9c62ee6c3d83b2619325c483c4b1fca6e05ec5fb2086a093b657a1134a4cfbc0a9e88e1651c2921e4395873984a9ecfb270fb2d80cc0abe81d34011d9a698a2ef010b296",
|
||||||
|
"67f95c74b28d84f9abdd997ff902a2c939233309b7cdf3ceb9f8f6481279d9ce": "823f81263f1aa30338c9331b1963aede4acd2ddf9db596185a083be01e031a782e1585eb8aaa3cb2299c5c9d342639e2dd90d185d808e2853ad4fcf429f59b96f80b189bfc1dc3f2ff6730e626a095214f23b32c3c23a24741ffe84e8b0bfa5103fcea2ceee5400a",
|
||||||
|
"78cf397476705026246ce615224cd1bf18794700548b057c7a90e78786df39c7": "f5e47fac464813aef68bd9661d7210bed4d0b54fe2d109231bab1a83c87eb488db60edd52b66e709ad9c3b0dd53560274bed9b58419f47dd84049160dfe09cf6b64482060a495de6ef428539eb644b7ee1083c1129f7123b3568379b60386807c08d04c54aa9a0f4",
|
||||||
|
"86f70be7eb60dc350e3de57e056f3af87276774d433c1e77ed24a8b1eeb7d715": "ab8af8ffc3f5e5137c37ca69bb8ae9671ed78bab330921d52176fca3b008a703158efc58c85fe13bde90018e5bf44e7d7725e926acf49d6049bf27ecf403671db670d10b1acc6bc576aa8115aabbd269f239cb9926068fc91ca6d877311b518d395240562441bfe7",
|
||||||
|
"91270f43597088394c9ca64411f181bbebd1a0d0b7f2d17e45c27ba1bac80a1d": "326215854b5c8a71c2080cdcc3a4ca9f29ef687c14e6f9b02765d90c5b12f0a9d0d41425f5d423bc37a4d8e6972b3c4f138cedb973ba044e5312d45f7304460f48326d695a99197814bbc9bac843beab4f32380490f70d103097648fe7d240592ea73427b6c3fbaa",
|
||||||
|
"a98be83e7246db94e58fe79d401e53c5ab277a21d2e22014315c731a81b80192": "5d5a6935055787936bc4c8196bfeb097a0e63ce6e87136f87370333bb13ec5eb46acd2362f406155fe548b4d2a4b9ff33973a2810cbaab9210637fced984a08a49a496b686adae36dc6c9428816782f0e19215bfe1b4c60180fcb96ffbcc8df8b76f50b9fe7cfb63",
|
||||||
|
"b3c711af3bf7a320e2ec55abe756b19cac49b95f42bb53ea678b0284c8004947": "4b7c7210adb026727773bd55af9e61dd0f25d54e816a0d9fd337f8300c2f1904409cb600e5f6d81980daea5a6e0415630668eb5d50a07d5533b5282eb740caa3cca923c00b0c61bc8fc62eaf66e7a34d975198ac36bb086608d71d947bfc3253be86eb9ad89c5276",
|
||||||
|
"bcd23e6a6aaed2208d424262d76618be4d5ab964638c64c56a72de40dae39d93": "1674f6d38326ee16d6f1cc1c2c168d0240f526b5043b1cf9fd2b8639c03fb3202c08270d05d5511f58302f72a207b1902c81084ce0078ad63cd6b6bad3d7198f362e8e1823091d7cb3ce569d9b34b1059e375d2fc1c0cedc2309f3b26164055eef84d0b1a8d4ac00",
|
||||||
|
"cb708142454b92baf8653df8dee9bd78a90b2f3a4b8a7f42e7395c1432d717cd": "1eaa8941ffe3f200b12f7e784513b3ccefcc66f9af30ba3f167141e9c63e381867e3c7236f81c2fbe260eb9f6c3bf45c7b9a4f1f39e36b41b8058f91bfe4ae491b91a7c0deac4f1cd1db75f490b815cce2bfdd90ac23208865a8b792808f2700e553700d568d61ef",
|
||||||
|
"e8c792004a815e03d201986c19bf7bb88a4e5d791b6fa21676ad5891c641f157": "29dcbcedb00ca30acd96ac0f69cc29e08e465f1bb554d1e3b558d118ea1aefbece3253ce09f46a33d4837d7d726a55d02b4a3f5eefb3d17d8651046f0fc0ff207fd3fa16186a201d9433bc4106748660853bb0eab4a8bf0bd09695590c2e54005ce71b3a7f14036c",
|
||||||
|
"ff68c63849bd293a5c55598c739911bcda6fa303465da9cd720ff0f45b557b17": "91dc4ece5bb67f798ef5a671f80201954743a4079282074068cc388c7e2de2207ad63919e6c8b29052c7d8e3a4ba7d355b213824d5d9729dcb56ecdd32deb1a0efbf4c59765ac282248016a98e03f69f1caff51b1a06071988ecc48d195fccfc8d2c60137581ac70"
|
||||||
}
|
}
|
@ -1,4 +1,8 @@
|
|||||||
:root {
|
/* =============================================
|
||||||
|
VARIABLES
|
||||||
|
============================================= */
|
||||||
|
:root {
|
||||||
|
/* Color Palette */
|
||||||
--primary-black: #121212;
|
--primary-black: #121212;
|
||||||
--secondary-black: #1e1e1e;
|
--secondary-black: #1e1e1e;
|
||||||
--primary-gray: #2d2d2d;
|
--primary-gray: #2d2d2d;
|
||||||
@ -8,8 +12,13 @@
|
|||||||
--secondary-purple: #7B68EE; /* Medium Slate Blue */
|
--secondary-purple: #7B68EE; /* Medium Slate Blue */
|
||||||
--dark-purple: #6A5ACD; /* Slate Blue */
|
--dark-purple: #6A5ACD; /* Slate Blue */
|
||||||
--text-color: #e0e0e0;
|
--text-color: #e0e0e0;
|
||||||
|
--success-color: #4CAF50;
|
||||||
|
--border-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
BASE ELEMENTS
|
||||||
|
============================================= */
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(135deg, var(--primary-black) 0%, var(--secondary-black) 100%);
|
background: linear-gradient(135deg, var(--primary-black) 0%, var(--secondary-black) 100%);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
@ -17,38 +26,9 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
/* =============================================
|
||||||
margin-bottom: 20px;
|
NAVIGATION
|
||||||
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 {
|
.navbar {
|
||||||
background: linear-gradient(90deg, var(--primary-black) 0%, var(--dark-purple) 100%) !important;
|
background: linear-gradient(90deg, var(--primary-black) 0%, var(--dark-purple) 100%) !important;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
@ -69,19 +49,59 @@ body {
|
|||||||
color: var(--primary-purple) !important;
|
color: var(--primary-purple) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
CARD ELEMENTS
|
||||||
|
============================================= */
|
||||||
|
.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);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
color: var(--light-gray) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
background-color: var(--secondary-gray) !important;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
BUTTONS
|
||||||
|
============================================= */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--primary-purple) !important;
|
background-color: var(--primary-purple) !important;
|
||||||
border-color: var(--primary-purple) !important;
|
border-color: var(--primary-purple) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover, .btn-primary:focus {
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
background-color: var(--secondary-purple) !important;
|
background-color: var(--secondary-purple) !important;
|
||||||
border-color: var(--secondary-purple) !important;
|
border-color: var(--secondary-purple) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background-color: #4CAF50 !important;
|
background-color: var(--success-color) !important;
|
||||||
border-color: #4CAF50 !important;
|
border-color: var(--success-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@ -89,6 +109,59 @@ body {
|
|||||||
border-color: var(--secondary-gray) !important;
|
border-color: var(--secondary-gray) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
FORMS
|
||||||
|
============================================= */
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--primary-black);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual post content area */
|
||||||
|
#manualPostContent {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
MODAL
|
||||||
|
============================================= */
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--secondary-gray);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
UTILITIES
|
||||||
|
============================================= */
|
||||||
.truncate {
|
.truncate {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -105,43 +178,9 @@ body {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
/* =============================================
|
||||||
background-color: var(--secondary-gray);
|
CUSTOM SCROLLBAR
|
||||||
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 {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
@ -157,4 +196,99 @@ body {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--secondary-purple);
|
background: var(--secondary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
CONTENT MANAGEMENT PAGE
|
||||||
|
============================================= */
|
||||||
|
/* Bot selection container */
|
||||||
|
.bot-selection-container {
|
||||||
|
background-color: var(--primary-gray);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image preview container - FIXED VERSION */
|
||||||
|
.upload-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--secondary-gray);
|
||||||
|
background-color: var(--primary-black);
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadPreviewContainer,
|
||||||
|
#mediaPreviewContainer {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--primary-black);
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve the media link containers */
|
||||||
|
.media-link {
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--secondary-black);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media upload container */
|
||||||
|
.media-upload-container {
|
||||||
|
background-color: var(--primary-gray);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server URL input improvements */
|
||||||
|
#primaryServerURL,
|
||||||
|
#fallbackServerURL {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: var(--primary-black);
|
||||||
|
border-color: var(--secondary-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better looking file list */
|
||||||
|
.list-group-item {
|
||||||
|
background-color: var(--secondary-gray);
|
||||||
|
border-color: var(--primary-black);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item:hover {
|
||||||
|
background-color: var(--primary-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
RESPONSIVE STYLES
|
||||||
|
============================================= */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selection-container {
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-preview {
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
}
|
}
|
513
web/assets/js/content.js
Normal file
513
web/assets/js/content.js
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
// content.js - JavaScript for the content management page
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// ===================================================
|
||||||
|
// Global Variables
|
||||||
|
// ===================================================
|
||||||
|
let currentBotId = null;
|
||||||
|
|
||||||
|
// Media server configuration (global for all bots)
|
||||||
|
let globalMediaConfig = {
|
||||||
|
primaryService: 'nip94',
|
||||||
|
primaryURL: '',
|
||||||
|
fallbackService: 'none',
|
||||||
|
fallbackURL: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Element References
|
||||||
|
// ===================================================
|
||||||
|
const botSelect = document.getElementById('botSelect');
|
||||||
|
const loadContentBtn = document.getElementById('loadContentBtn');
|
||||||
|
const uploadFileInput = document.getElementById('uploadFileInput');
|
||||||
|
const uploadPreviewContainer = document.getElementById('uploadPreviewContainer');
|
||||||
|
const uploadPreview = document.getElementById('uploadPreview');
|
||||||
|
const uploadButton = document.getElementById('uploadButton');
|
||||||
|
const contentContainer = document.getElementById('contentContainer');
|
||||||
|
|
||||||
|
// Post creation elements
|
||||||
|
const postKindRadios = document.querySelectorAll('input[name="postKind"]');
|
||||||
|
const titleField = document.getElementById('titleField');
|
||||||
|
const postTitle = document.getElementById('postTitle');
|
||||||
|
const manualPostContent = document.getElementById('manualPostContent');
|
||||||
|
const postHashtags = document.getElementById('postHashtags');
|
||||||
|
const postMediaInput = document.getElementById('postMediaInput');
|
||||||
|
const quickUploadBtn = document.getElementById('quickUploadBtn');
|
||||||
|
const mediaPreviewContainer = document.getElementById('mediaPreviewContainer');
|
||||||
|
const mediaPreview = document.getElementById('mediaPreview');
|
||||||
|
const mediaLinkContainer = document.getElementById('mediaLinkContainer');
|
||||||
|
const submitPostBtn = document.getElementById('submitPostBtn');
|
||||||
|
|
||||||
|
// Media server settings
|
||||||
|
const primaryServer = document.getElementById('primaryServer');
|
||||||
|
const fallbackServer = document.getElementById('fallbackServer');
|
||||||
|
const saveMediaSettingsBtn = document.getElementById('saveMediaSettingsBtn');
|
||||||
|
const primaryServerURL = document.getElementById('primaryServerURL');
|
||||||
|
const fallbackServerURL = document.getElementById('fallbackServerURL');
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Event Listeners
|
||||||
|
// ===================================================
|
||||||
|
|
||||||
|
// Check auth and load bots first
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// Load bot choices after a short delay to ensure auth is checked
|
||||||
|
setTimeout(() => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (token) {
|
||||||
|
loadBotChoices();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Button click handlers
|
||||||
|
if (loadContentBtn) loadContentBtn.addEventListener('click', handleLoadContent);
|
||||||
|
if (uploadButton) uploadButton.addEventListener('click', handleFileUpload);
|
||||||
|
if (quickUploadBtn) quickUploadBtn.addEventListener('click', handleQuickUpload);
|
||||||
|
if (submitPostBtn) submitPostBtn.addEventListener('click', handleSubmitPost);
|
||||||
|
if (saveMediaSettingsBtn) {
|
||||||
|
saveMediaSettingsBtn.addEventListener('click', handleSaveMediaSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File input change handlers
|
||||||
|
if (uploadFileInput) {
|
||||||
|
uploadFileInput.addEventListener('change', (e) => {
|
||||||
|
previewFile(e.target.files[0], uploadPreview, uploadPreviewContainer);
|
||||||
|
uploadButton.disabled = !e.target.files.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postMediaInput) {
|
||||||
|
postMediaInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
mediaPreviewContainer.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewFile(file, mediaPreview, mediaPreviewContainer);
|
||||||
|
|
||||||
|
// Show filename if not an image
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
mediaPreview.style.display = 'none';
|
||||||
|
mediaLinkContainer.innerHTML =
|
||||||
|
`<p class="mb-0">Selected: ${file.name}</p>
|
||||||
|
<small class="text-muted">Click "Upload" to get media URL</small>`;
|
||||||
|
} else {
|
||||||
|
mediaPreview.style.display = 'block';
|
||||||
|
mediaLinkContainer.innerHTML =
|
||||||
|
'<small class="text-muted">Click "Upload" to get media URL</small>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post kind radio button change handler
|
||||||
|
if (postKindRadios.length) {
|
||||||
|
postKindRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
titleField.classList.toggle('d-none', radio.value !== '20');
|
||||||
|
|
||||||
|
// Show/hide required media elements for kind 20
|
||||||
|
const isKind20 = radio.value === '20';
|
||||||
|
const kind20MediaRequired = document.getElementById('kind20MediaRequired');
|
||||||
|
if (kind20MediaRequired) {
|
||||||
|
kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For kind 20, validate that media is provided before posting
|
||||||
|
if (submitPostBtn) {
|
||||||
|
submitPostBtn.title = isKind20 ?
|
||||||
|
'Picture posts require a title and an image URL' :
|
||||||
|
'Create a standard text post';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Event Handler Functions
|
||||||
|
// ===================================================
|
||||||
|
|
||||||
|
// Load content when button is clicked
|
||||||
|
function handleLoadContent() {
|
||||||
|
if (!botSelect || !botSelect.value) {
|
||||||
|
alert('Please select a bot first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBotId = botSelect.value;
|
||||||
|
contentContainer.classList.remove('d-none');
|
||||||
|
|
||||||
|
loadBotContent(currentBotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload button click
|
||||||
|
function handleFileUpload() {
|
||||||
|
if (!currentBotId) {
|
||||||
|
alert('Please select a bot first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadFileInput.files.length) {
|
||||||
|
alert('Please select a file to upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBotFile(currentBotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle quick upload for post creation
|
||||||
|
function handleQuickUpload() {
|
||||||
|
if (!currentBotId) {
|
||||||
|
alert('Please select a bot first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!postMediaInput.files.length) {
|
||||||
|
alert('Please select a file to upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quickUploadMedia(currentBotId, postMediaInput.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle post submission
|
||||||
|
function handleSubmitPost() {
|
||||||
|
if (!currentBotId) {
|
||||||
|
alert('Please select a bot first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createManualPost(currentBotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle saving media server settings
|
||||||
|
function handleSaveMediaSettings() {
|
||||||
|
globalMediaConfig.primaryService = primaryServer.value;
|
||||||
|
globalMediaConfig.primaryURL = primaryServerURL ? primaryServerURL.value.trim() : '';
|
||||||
|
globalMediaConfig.fallbackService = fallbackServer.value === 'none' ? '' : fallbackServer.value;
|
||||||
|
globalMediaConfig.fallbackURL = fallbackServerURL ? fallbackServerURL.value.trim() : '';
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('mediaConfig', JSON.stringify(globalMediaConfig));
|
||||||
|
|
||||||
|
alert('Media server settings saved!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ===================================================
|
||||||
|
|
||||||
|
// Preview a file (image or video)
|
||||||
|
function previewFile(file, previewElement, containerElement) {
|
||||||
|
if (!file) {
|
||||||
|
containerElement.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerElement.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Show preview for images
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
previewElement.src = e.target.result;
|
||||||
|
previewElement.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
// For non-images (video, etc)
|
||||||
|
previewElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates post data before submission
|
||||||
|
function validatePostData() {
|
||||||
|
const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value);
|
||||||
|
const content = manualPostContent.value.trim();
|
||||||
|
|
||||||
|
// Basic validation for all post types
|
||||||
|
if (!content) {
|
||||||
|
alert('Post content is required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional validation for kind 20 posts
|
||||||
|
if (kind === 20) {
|
||||||
|
const title = postTitle.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
alert('Title is required for Picture Posts (kind: 20)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a media URL either in the content or in the mediaUrlInput
|
||||||
|
const mediaUrl = mediaUrlInput.value.trim();
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
const contentContainsUrl = urlRegex.test(content);
|
||||||
|
|
||||||
|
if (!mediaUrl && !contentContainsUrl) {
|
||||||
|
alert('Picture posts require an image URL. Please upload an image or enter a URL in the Media URL field or in the content.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// API Functions
|
||||||
|
// ===================================================
|
||||||
|
|
||||||
|
// Load bot content files
|
||||||
|
function loadBotContent(botId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
// Load any saved media config from localStorage
|
||||||
|
const savedConfig = localStorage.getItem('mediaConfig');
|
||||||
|
if (savedConfig) {
|
||||||
|
try {
|
||||||
|
globalMediaConfig = JSON.parse(savedConfig);
|
||||||
|
if (primaryServer) primaryServer.value = globalMediaConfig.primaryService || 'nip94';
|
||||||
|
if (primaryServerURL) primaryServerURL.value = globalMediaConfig.primaryURL || '';
|
||||||
|
if (fallbackServer) {
|
||||||
|
fallbackServer.value = globalMediaConfig.fallbackService || 'none';
|
||||||
|
}
|
||||||
|
if (fallbackServerURL) {
|
||||||
|
fallbackServerURL.value = globalMediaConfig.fallbackURL || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing saved media config:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch content files
|
||||||
|
fetch(`/api/content/${botId}`, {
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error('Failed to list content');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(files => {
|
||||||
|
renderContentFiles(botId, files);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error loading content:', err);
|
||||||
|
alert('Error loading content: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the list of content files
|
||||||
|
function renderContentFiles(botId, files) {
|
||||||
|
const contentArea = document.getElementById('contentArea');
|
||||||
|
if (!contentArea) return;
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
contentArea.innerHTML = '<p>No files found. Upload some content!</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul class="list-group">';
|
||||||
|
for (const file of files) {
|
||||||
|
html += `
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
${file}
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteBotFile('${botId}', '${file}')">Delete</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
|
||||||
|
contentArea.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload media for the manual post
|
||||||
|
function quickUploadMedia(botId, file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
quickUploadBtn.disabled = true;
|
||||||
|
quickUploadBtn.textContent = 'Uploading...';
|
||||||
|
|
||||||
|
// Show loading state in preview
|
||||||
|
mediaLinkContainer.innerHTML =
|
||||||
|
'<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Uploading...';
|
||||||
|
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
|
||||||
|
// First upload the file to our server
|
||||||
|
fetch(`/api/content/${botId}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': token },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Now upload to the media server
|
||||||
|
return fetch(`/api/content/${botId}/uploadToMediaServer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: data.filename,
|
||||||
|
service: globalMediaConfig.primaryService,
|
||||||
|
serverURL: globalMediaConfig.primaryURL
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error('Media server upload failed');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// Reset button state
|
||||||
|
quickUploadBtn.disabled = false;
|
||||||
|
quickUploadBtn.textContent = 'Upload';
|
||||||
|
|
||||||
|
// Insert the media URL into the post content
|
||||||
|
const textArea = document.getElementById('manualPostContent');
|
||||||
|
let mediaUrl = data.url;
|
||||||
|
|
||||||
|
// Also update the media URL input field
|
||||||
|
const mediaUrlInput = document.getElementById('mediaUrlInput');
|
||||||
|
if (mediaUrlInput) {
|
||||||
|
mediaUrlInput.value = mediaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview with media info
|
||||||
|
mediaLinkContainer.innerHTML =
|
||||||
|
`<p class="mb-0">Media URL:</p>
|
||||||
|
<code class="text-info">${mediaUrl}</code>`;
|
||||||
|
|
||||||
|
// Insert into text area
|
||||||
|
textArea.value += (textArea.value ? '\n\n' : '') + mediaUrl;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
quickUploadBtn.disabled = false;
|
||||||
|
quickUploadBtn.textContent = 'Upload';
|
||||||
|
mediaLinkContainer.innerHTML =
|
||||||
|
`<p class="text-danger">Upload error: ${err.message}</p>`;
|
||||||
|
alert('Upload error: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a manual post (improved for kind 20 posts)
|
||||||
|
function createManualPost(botId) {
|
||||||
|
// Validate the form data first
|
||||||
|
if (!validatePostData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = manualPostContent.value.trim();
|
||||||
|
const hashtagsValue = postHashtags.value.trim();
|
||||||
|
const hashtags = hashtagsValue
|
||||||
|
? hashtagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value);
|
||||||
|
let title = '';
|
||||||
|
|
||||||
|
if (kind === 20) {
|
||||||
|
title = postTitle.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract media URLs and alt text
|
||||||
|
const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : '';
|
||||||
|
const altText = mediaAltText ? mediaAltText.value.trim() : '';
|
||||||
|
|
||||||
|
// Build the post data based on kind
|
||||||
|
const postData = {
|
||||||
|
kind: kind,
|
||||||
|
content: content,
|
||||||
|
hashtags: hashtags
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind === 20) {
|
||||||
|
postData.title = title;
|
||||||
|
|
||||||
|
// For kind 20, we need to ensure we have a valid URL
|
||||||
|
// If we have a specific media URL field value, add it to the content if not already there
|
||||||
|
if (mediaUrl && !content.includes(mediaUrl)) {
|
||||||
|
postData.content = content + '\n\n' + mediaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add alt text if provided
|
||||||
|
if (altText) {
|
||||||
|
postData.alt = altText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable submit button during request
|
||||||
|
submitPostBtn.disabled = true;
|
||||||
|
submitPostBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Posting...';
|
||||||
|
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
fetch(`/api/bots/${botId}/post`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(postData)
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.json().then(data => {
|
||||||
|
throw new Error(data.error || 'Failed to create post');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
alert('Post created successfully!');
|
||||||
|
console.log('Post success response:', data);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
manualPostContent.value = '';
|
||||||
|
postHashtags.value = '';
|
||||||
|
postTitle.value = '';
|
||||||
|
postMediaInput.value = '';
|
||||||
|
if (mediaUrlInput) mediaUrlInput.value = '';
|
||||||
|
if (mediaAltText) mediaAltText.value = '';
|
||||||
|
mediaPreviewContainer.classList.add('d-none');
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
submitPostBtn.disabled = false;
|
||||||
|
submitPostBtn.textContent = 'Post Now';
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error creating post:', err);
|
||||||
|
alert('Error creating post: ' + err.message);
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
submitPostBtn.disabled = false;
|
||||||
|
submitPostBtn.textContent = 'Post Now';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Initialize media config
|
||||||
|
// ===================================================
|
||||||
|
// Try to load media config from localStorage
|
||||||
|
const savedConfig = localStorage.getItem('mediaConfig');
|
||||||
|
if (savedConfig) {
|
||||||
|
try {
|
||||||
|
globalMediaConfig = JSON.parse(savedConfig);
|
||||||
|
if (primaryServer) primaryServer.value = globalMediaConfig.primaryService || 'nip94';
|
||||||
|
if (primaryServerURL) primaryServerURL.value = globalMediaConfig.primaryURL || '';
|
||||||
|
if (fallbackServer) {
|
||||||
|
fallbackServer.value = globalMediaConfig.fallbackService || 'none';
|
||||||
|
}
|
||||||
|
if (fallbackServerURL) {
|
||||||
|
fallbackServerURL.value = globalMediaConfig.fallbackURL || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing saved media config:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@ -1,5 +1,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// DOM Elements
|
/* ----------------------------------------------------
|
||||||
|
* DOM Elements
|
||||||
|
* -------------------------------------------------- */
|
||||||
const loginButton = document.getElementById('loginButton');
|
const loginButton = document.getElementById('loginButton');
|
||||||
const logoutButton = document.getElementById('logoutButton');
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
const userInfo = document.getElementById('userInfo');
|
const userInfo = document.getElementById('userInfo');
|
||||||
@ -9,30 +11,134 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const botsList = document.getElementById('bots-list');
|
const botsList = document.getElementById('bots-list');
|
||||||
const createBotBtn = document.getElementById('create-bot-btn');
|
const createBotBtn = document.getElementById('create-bot-btn');
|
||||||
const saveBotBtn = document.getElementById('save-bot-btn');
|
const saveBotBtn = document.getElementById('save-bot-btn');
|
||||||
const generateKeypair = document.getElementById('generateKeypair');
|
|
||||||
const keypairInput = document.getElementById('keypair-input');
|
|
||||||
|
|
||||||
// Bootstrap Modal
|
// Modal elements for new bot creation
|
||||||
|
const createBotModalEl = document.getElementById('createBotModal');
|
||||||
|
// New form fields for key import/generation
|
||||||
|
const keyOption = document.getElementById('keyOption');
|
||||||
|
const nsecKeyInput = document.getElementById('nsecKeyInput');
|
||||||
|
const toggleNsecKey = document.getElementById('toggleNsecKey');
|
||||||
|
|
||||||
|
// If you have a "bot settings" modal:
|
||||||
|
// (IDs must match the modals in your HTML)
|
||||||
|
const botSettingsModalEl = document.getElementById('botSettingsModal');
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Bootstrap Modal instance
|
||||||
|
* -------------------------------------------------- */
|
||||||
let createBotModal;
|
let createBotModal;
|
||||||
if (typeof bootstrap !== 'undefined') {
|
if (typeof bootstrap !== 'undefined' && createBotModalEl) {
|
||||||
createBotModal = new bootstrap.Modal(document.getElementById('createBotModal'));
|
createBotModal = new bootstrap.Modal(createBotModalEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// State
|
// If you have a Bot Settings modal
|
||||||
let currentUser = null;
|
let botSettingsModal;
|
||||||
const API_ENDPOINT = '';
|
if (typeof bootstrap !== 'undefined' && botSettingsModalEl) {
|
||||||
|
botSettingsModal = new bootstrap.Modal(botSettingsModalEl);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already logged in
|
/* ----------------------------------------------------
|
||||||
|
* Global State
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
let currentUser = null;
|
||||||
|
const API_ENDPOINT = ''; // <--- If your server is at http://localhost:8765, then use that. Example: 'http://localhost:8765'
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* On page load, check if already logged in
|
||||||
|
* -------------------------------------------------- */
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
// Event Listeners
|
/* ----------------------------------------------------
|
||||||
loginButton.addEventListener('click', login);
|
* Event Listeners
|
||||||
logoutButton.addEventListener('click', logout);
|
* -------------------------------------------------- */
|
||||||
createBotBtn.addEventListener('click', showCreateBotModal);
|
if (loginButton) loginButton.addEventListener('click', login);
|
||||||
saveBotBtn.addEventListener('click', createBot);
|
if (logoutButton) logoutButton.addEventListener('click', logout);
|
||||||
generateKeypair.addEventListener('change', toggleKeypairInput);
|
|
||||||
|
|
||||||
// Functions
|
if (createBotBtn) createBotBtn.addEventListener('click', showCreateBotModal);
|
||||||
|
if (saveBotBtn) saveBotBtn.addEventListener('click', createBot);
|
||||||
|
|
||||||
|
document.body.addEventListener('click', (evt) => {
|
||||||
|
const viewBtn = evt.target.closest('.view-bot');
|
||||||
|
if (viewBtn) {
|
||||||
|
const botId = viewBtn.dataset.id;
|
||||||
|
if (botId) {
|
||||||
|
openBotSettings(botId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle "keyOption" dropdown changes
|
||||||
|
if (keyOption) {
|
||||||
|
keyOption.addEventListener('change', function () {
|
||||||
|
if (this.value === 'import') {
|
||||||
|
nsecKeyInput.style.display = 'block';
|
||||||
|
// Focus the NSEC input field (slight delay to ensure DOM is ready)
|
||||||
|
setTimeout(() => {
|
||||||
|
const nk = document.getElementById('botNsecKey');
|
||||||
|
if (nk) nk.focus();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
nsecKeyInput.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle NSEC key visibility
|
||||||
|
if (toggleNsecKey) {
|
||||||
|
toggleNsecKey.addEventListener('click', function () {
|
||||||
|
const nsecKeyField = document.getElementById('botNsecKey');
|
||||||
|
if (!nsecKeyField) return;
|
||||||
|
|
||||||
|
if (nsecKeyField.type === 'password') {
|
||||||
|
nsecKeyField.type = 'text';
|
||||||
|
this.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
|
class="bi bi-eye-slash" viewBox="0 0 16 16">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
nsecKeyField.type = 'password';
|
||||||
|
this.innerHTML = `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Authentication / Login / Logout
|
||||||
|
* -------------------------------------------------- */
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -42,15 +148,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, {
|
const response = await fetch(`${API_ENDPOINT}/api/auth/verify`, {
|
||||||
headers: {
|
headers: { 'Authorization': token }
|
||||||
'Authorization': token
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
currentUser = data.pubkey;
|
|
||||||
|
// data.pubkey is currently hex
|
||||||
|
// Convert to npub using nip19 from nostr-tools
|
||||||
|
const { nip19 } = window.nostrTools;
|
||||||
|
const userNpub = nip19.npubEncode(data.pubkey);
|
||||||
|
|
||||||
|
// Store npub as currentUser
|
||||||
|
currentUser = userNpub;
|
||||||
|
|
||||||
showMainContent();
|
showMainContent();
|
||||||
|
// Always load bots if on the main page
|
||||||
fetchBots();
|
fetchBots();
|
||||||
} else {
|
} else {
|
||||||
// Token invalid
|
// Token invalid
|
||||||
@ -63,6 +176,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (!window.nostr) {
|
if (!window.nostr) {
|
||||||
alert('Nostr extension not found. Please install a NIP-07 compatible extension like nos2x or Alby.');
|
alert('Nostr extension not found. Please install a NIP-07 compatible extension like nos2x or Alby.');
|
||||||
@ -72,7 +186,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
try {
|
try {
|
||||||
// Get user's public key
|
// Get user's public key
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
|
||||||
// Create challenge event for signing
|
// Create challenge event for signing
|
||||||
const event = {
|
const event = {
|
||||||
kind: 22242,
|
kind: 22242,
|
||||||
@ -83,13 +197,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Sign the event
|
// Sign the event
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
// Send to server for verification
|
// Send to server for verification
|
||||||
const response = await fetch(`${API_ENDPOINT}/api/auth/login`, {
|
const response = await fetch(`${API_ENDPOINT}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pubkey: pubkey,
|
pubkey: pubkey,
|
||||||
signature: signedEvent.sig,
|
signature: signedEvent.sig,
|
||||||
@ -118,33 +230,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
showAuthSection();
|
showAuthSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* UI State Helpers
|
||||||
|
* -------------------------------------------------- */
|
||||||
function showAuthSection() {
|
function showAuthSection() {
|
||||||
authSection.classList.remove('d-none');
|
if (authSection) authSection.classList.remove('d-none');
|
||||||
mainContent.classList.add('d-none');
|
if (mainContent) mainContent.classList.add('d-none');
|
||||||
loginButton.classList.remove('d-none');
|
if (loginButton) loginButton.classList.remove('d-none');
|
||||||
userInfo.classList.add('d-none');
|
if (userInfo) userInfo.classList.add('d-none');
|
||||||
logoutButton.classList.add('d-none');
|
if (logoutButton) logoutButton.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMainContent() {
|
function showMainContent() {
|
||||||
authSection.classList.add('d-none');
|
if (authSection) authSection.classList.add('d-none');
|
||||||
mainContent.classList.remove('d-none');
|
if (mainContent) mainContent.classList.remove('d-none');
|
||||||
loginButton.classList.add('d-none');
|
if (loginButton) loginButton.classList.add('d-none');
|
||||||
userInfo.classList.remove('d-none');
|
if (userInfo) userInfo.classList.remove('d-none');
|
||||||
logoutButton.classList.remove('d-none');
|
if (logoutButton) logoutButton.classList.remove('d-none');
|
||||||
|
|
||||||
// Truncate pubkey for display
|
// Truncate pubkey for display
|
||||||
const shortPubkey = currentUser.substring(0, 8) + '...' + currentUser.substring(currentUser.length - 4);
|
if (userPubkey && currentUser) {
|
||||||
userPubkey.textContent = shortPubkey;
|
const shortPubkey = currentUser.substring(0, 8) + '...' +
|
||||||
|
currentUser.substring(currentUser.length - 4);
|
||||||
|
userPubkey.textContent = shortPubkey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Bots List
|
||||||
|
* -------------------------------------------------- */
|
||||||
async function fetchBots() {
|
async function fetchBots() {
|
||||||
|
// If there's no #bots-list, skip
|
||||||
|
if (!botsList) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||||
headers: {
|
headers: { 'Authorization': token }
|
||||||
'Authorization': token
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -159,21 +282,67 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBots(bots) {
|
function renderBots(bots) {
|
||||||
botsList.innerHTML = '';
|
if (!botsList) return;
|
||||||
|
|
||||||
if (bots.length === 0) {
|
botsList.innerHTML = '';
|
||||||
|
|
||||||
|
if (!bots || bots.length === 0) {
|
||||||
botsList.innerHTML = `
|
botsList.innerHTML = `
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body text-center py-5">
|
<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">
|
<!-- Robot Icon -->
|
||||||
<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"/>
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<p class="lead">You don't have any bots yet.</p>
|
<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()">
|
<button class="btn btn-primary mt-3"
|
||||||
<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">
|
onclick="document.getElementById('create-bot-btn').click()">
|
||||||
<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 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>
|
</svg>
|
||||||
Create Your First Bot
|
Create Your First Bot
|
||||||
</button>
|
</button>
|
||||||
@ -185,15 +354,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bots.forEach(bot => {
|
bots.forEach(bot => {
|
||||||
// Create a status badge
|
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
if (bot.post_config?.enabled) {
|
if (bot.post_config?.enabled) {
|
||||||
statusBadge = '<span class="badge bg-success">Active</span>';
|
statusBadge = '<span class="badge bg-success">Active</span>';
|
||||||
} else {
|
} else {
|
||||||
statusBadge = '<span class="badge bg-secondary">Inactive</span>';
|
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 profileColor = generateColorFromString(bot.pubkey);
|
||||||
const initials = (bot.name || 'Bot').substring(0, 2).toUpperCase();
|
const initials = (bot.name || 'Bot').substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
@ -203,7 +370,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="card bot-card h-100">
|
<div class="card bot-card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<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;">
|
<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}
|
${initials}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -220,16 +390,72 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<button class="btn btn-sm btn-primary view-bot" data-id="${bot.id}">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||||
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
fill="currentColor" class="bi bi-eye-fill me-1"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm ${bot.post_config?.enabled ? 'btn-outline-danger' : 'btn-outline-success'}">
|
|
||||||
${bot.post_config?.enabled ?
|
<!-- For enabling/disabling -->
|
||||||
'<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' :
|
${bot.post_config?.enabled
|
||||||
'<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 class="btn btn-sm btn-outline-danger" onclick="disableBot(${bot.id})">
|
||||||
|
<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
|
||||||
|
</button>`
|
||||||
|
: `<button class="btn btn-sm btn-outline-success" onclick="enableBot(${bot.id})">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ADD DELETE BUTTON BELOW, for example: -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteBot(${bot.id})">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||||
|
fill="currentColor" class="bi bi-trash me-1"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6
|
||||||
|
5h4a.5.5 0 0 1 0 1H6a.5.5 0
|
||||||
|
0 1-.5-.5zm2 3a.5.5 0 0 1
|
||||||
|
.5-.5h1a.5.5 0 0 1 0 1h-1a.5.5
|
||||||
|
0 0 1-.5-.5z"/>
|
||||||
|
<path d="M14 3a1 1 0 0 1-1
|
||||||
|
1H3a1 1 0 0 1-1-1H0v1a2
|
||||||
|
2 0 0 0 2 2v9a2 2 0 0 0
|
||||||
|
2 2h8a2 2 0 0 0 2-2V6a2
|
||||||
|
2 0 0 0 2-2V3h-2zM2.5
|
||||||
|
5h11v9a1 1 0 0 1-1
|
||||||
|
1h-9a1 1 0 0
|
||||||
|
1-1-1V5z"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -244,55 +470,78 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the hash to create a hue in the purple range (260-290)
|
// Use the hash to create a hue in the purple range (260-290)
|
||||||
const hue = ((hash % 30) + 260) % 360;
|
const hue = ((hash % 30) + 260) % 360;
|
||||||
return `hsl(${hue}, 70%, 60%)`;
|
return `hsl(${hue}, 70%, 60%)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Show Create Bot Modal
|
||||||
|
* -------------------------------------------------- */
|
||||||
function showCreateBotModal() {
|
function showCreateBotModal() {
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('create-bot-form').reset();
|
const createBotForm = document.getElementById('create-bot-form');
|
||||||
generateKeypair.checked = true;
|
if (createBotForm) createBotForm.reset();
|
||||||
keypairInput.classList.add('d-none');
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
createBotModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleKeypairInput() {
|
// Default: keyOption = "generate" → hide nsecKeyInput
|
||||||
if (generateKeypair.checked) {
|
if (keyOption) {
|
||||||
keypairInput.classList.add('d-none');
|
keyOption.value = 'generate';
|
||||||
} else {
|
}
|
||||||
keypairInput.classList.remove('d-none');
|
if (nsecKeyInput) {
|
||||||
|
nsecKeyInput.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
if (createBotModal) {
|
||||||
|
createBotModal.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Create Bot (merged logic)
|
||||||
|
* -------------------------------------------------- */
|
||||||
async function createBot() {
|
async function createBot() {
|
||||||
const name = document.getElementById('botName').value;
|
console.clear();
|
||||||
const displayName = document.getElementById('botDisplayName').value;
|
console.log('Creating new bot...');
|
||||||
const bio = document.getElementById('botBio').value;
|
|
||||||
const nip05 = document.getElementById('botNip05').value;
|
// Get form values
|
||||||
|
const name = document.getElementById('botName').value.trim();
|
||||||
|
const displayName = document.getElementById('botDisplayName').value.trim();
|
||||||
|
const bio = document.getElementById('botBio').value.trim();
|
||||||
|
const nip05 = document.getElementById('botNip05').value.trim();
|
||||||
|
const keyChoice = keyOption ? keyOption.value : 'generate'; // fallback
|
||||||
|
|
||||||
|
// Validate form
|
||||||
if (!name) {
|
if (!name) {
|
||||||
alert('Bot name is required.');
|
alert('Bot name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pubkey = '';
|
// Build request data
|
||||||
let privkey = '';
|
const requestData = {
|
||||||
|
name: name,
|
||||||
|
display_name: displayName,
|
||||||
|
bio: bio,
|
||||||
|
nip05: nip05
|
||||||
|
};
|
||||||
|
|
||||||
if (!generateKeypair.checked) {
|
// If user selected "import", grab the NSEC key
|
||||||
pubkey = document.getElementById('botPubkey').value;
|
if (keyChoice === 'import') {
|
||||||
privkey = document.getElementById('botPrivkey').value;
|
const nsecKey = document.getElementById('botNsecKey').value.trim();
|
||||||
|
if (!nsecKey) {
|
||||||
if (!pubkey || !privkey) {
|
alert('NSEC key is required when importing an existing key.');
|
||||||
alert('Both public and private keys are required when not generating a keypair.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
requestData.encrypted_privkey = nsecKey;
|
||||||
|
console.log('Using imported NSEC key (starts with):', nsecKey.substring(0, 4) + '...');
|
||||||
|
} else {
|
||||||
|
console.log('Using auto-generated keypair');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Sending request to create bot...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('authToken');
|
const token = localStorage.getItem('authToken');
|
||||||
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
const response = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||||
@ -301,22 +550,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': token
|
'Authorization': token
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestData)
|
||||||
name,
|
|
||||||
display_name: displayName,
|
|
||||||
bio,
|
|
||||||
nip05,
|
|
||||||
pubkey,
|
|
||||||
encrypted_privkey: privkey
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
const rawResponse = await response.text();
|
||||||
|
console.log('Response text:', rawResponse.substring(0, 200) + '...');
|
||||||
|
|
||||||
|
let jsonResponse;
|
||||||
|
try {
|
||||||
|
jsonResponse = JSON.parse(rawResponse);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse JSON response:', e);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
createBotModal.hide();
|
console.log('Bot created successfully!');
|
||||||
|
// Hide modal
|
||||||
|
if (typeof bootstrap !== 'undefined') {
|
||||||
|
const modal = bootstrap.Modal.getInstance(createBotModalEl);
|
||||||
|
if (modal) modal.hide();
|
||||||
|
}
|
||||||
|
// Refresh bot list
|
||||||
fetchBots();
|
fetchBots();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const errorMsg = jsonResponse && jsonResponse.error
|
||||||
alert(`Failed to create bot: ${error.error}`);
|
? jsonResponse.error
|
||||||
|
: `Failed with status ${response.status}`;
|
||||||
|
alert('Failed to create bot: ' + errorMsg);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating bot:', error);
|
console.error('Error creating bot:', error);
|
||||||
@ -324,28 +585,359 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePrivkeyBtn = document.getElementById('togglePrivkey');
|
/* ----------------------------------------------------
|
||||||
if (togglePrivkeyBtn) {
|
* Enable Bot
|
||||||
togglePrivkeyBtn.addEventListener('click', function() {
|
* -------------------------------------------------- */
|
||||||
const privkeyInput = document.getElementById('botPrivkey');
|
window.enableBot = async function (botId) {
|
||||||
const eyeIcon = this.querySelector('svg');
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
if (privkeyInput.type === 'password') {
|
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/enable`, {
|
||||||
privkeyInput.type = 'text';
|
method: 'POST',
|
||||||
eyeIcon.innerHTML = `
|
headers: {
|
||||||
<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"/>
|
'Authorization': token
|
||||||
<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"/>
|
});
|
||||||
`;
|
if (!response.ok) {
|
||||||
} else {
|
const errData = await response.json().catch(() => ({}));
|
||||||
privkeyInput.type = 'password';
|
throw new Error(errData.error || 'Failed to enable bot');
|
||||||
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"/>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
});
|
alert('Bot enabled successfully!');
|
||||||
|
// Reload bots
|
||||||
|
fetchBots();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error enabling bot:', err);
|
||||||
|
alert(`Error enabling bot: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Disable Bot
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
window.disableBot = async function (botId) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/disable`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || 'Failed to disable bot');
|
||||||
|
}
|
||||||
|
alert('Bot paused/stopped successfully!');
|
||||||
|
// Refresh the list so it shows "Inactive"
|
||||||
|
fetchBots();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error disabling bot:', err);
|
||||||
|
alert(`Error disabling bot: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Settings Window
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
window.openBotSettings = async function (botId) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load bot data');
|
||||||
|
}
|
||||||
|
const bot = await response.json();
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
document.getElementById('botSettingsName').value = bot.name || '';
|
||||||
|
document.getElementById('botSettingsDisplayName').value = bot.display_name || '';
|
||||||
|
document.getElementById('botSettingsBio').value = bot.bio || '';
|
||||||
|
document.getElementById('botSettingsNip05').value = bot.nip05 || '';
|
||||||
|
document.getElementById('botSettingsZap').value = bot.zap_address || '';
|
||||||
|
|
||||||
|
// If post_config is present
|
||||||
|
if (bot.post_config) {
|
||||||
|
document.getElementById('botSettingsInterval').value = bot.post_config.interval_minutes || 60;
|
||||||
|
|
||||||
|
// hashtags is stored as a JSON string
|
||||||
|
const hashtagsJson = bot.post_config.hashtags || '[]';
|
||||||
|
const tagsArr = JSON.parse(hashtagsJson);
|
||||||
|
document.getElementById('botSettingsHashtags').value = tagsArr.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const modalEl = document.getElementById('botSettingsModal');
|
||||||
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// Store bot ID so we know which bot to save
|
||||||
|
document.getElementById('botSettingsSaveBtn').setAttribute('data-bot-id', botId);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading bot data:', err);
|
||||||
|
alert('Error loading bot: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.saveBotSettings = async function () {
|
||||||
|
const botId = document.getElementById('botSettingsSaveBtn').getAttribute('data-bot-id');
|
||||||
|
if (!botId) {
|
||||||
|
return alert('No bot ID found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic info
|
||||||
|
const name = document.getElementById('botSettingsName').value.trim();
|
||||||
|
const displayName = document.getElementById('botSettingsDisplayName').value.trim();
|
||||||
|
const bio = document.getElementById('botSettingsBio').value.trim();
|
||||||
|
const nip05 = document.getElementById('botSettingsNip05').value.trim();
|
||||||
|
const zap = document.getElementById('botSettingsZap').value.trim();
|
||||||
|
|
||||||
|
// 1) Update the basic fields (PUT /api/bots/:id)
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const updateResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
display_name: displayName,
|
||||||
|
bio,
|
||||||
|
nip05,
|
||||||
|
zap_address: zap
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!updateResp.ok) {
|
||||||
|
const errData = await updateResp.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || 'Failed to update bot info');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update bot info:', err);
|
||||||
|
alert('Error updating bot info: ' + err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Update the post config (PUT /api/bots/:id/config)
|
||||||
|
const intervalValue = parseInt(document.getElementById('botSettingsInterval').value, 10) || 60;
|
||||||
|
const hashtagsStr = document.getElementById('botSettingsHashtags').value.trim();
|
||||||
|
const hashtagsArr = hashtagsStr.length ? hashtagsStr.split(',').map(s => s.trim()) : [];
|
||||||
|
|
||||||
|
const configPayload = {
|
||||||
|
post_config: {
|
||||||
|
interval_minutes: intervalValue,
|
||||||
|
hashtags: JSON.stringify(hashtagsArr)
|
||||||
|
// We do not override 'enabled' here, so it remains as is
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const configResp = await fetch(`${API_ENDPOINT}/api/bots/${botId}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(configPayload)
|
||||||
|
});
|
||||||
|
if (!configResp.ok) {
|
||||||
|
const errData = await configResp.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || 'Failed to update post config');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update post config:', err);
|
||||||
|
alert('Error updating post config: ' + err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Bot settings updated!');
|
||||||
|
|
||||||
|
// Hide modal
|
||||||
|
const modalEl = document.getElementById('botSettingsModal');
|
||||||
|
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modal) modal.hide();
|
||||||
|
|
||||||
|
// Reload bots
|
||||||
|
fetchBots();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Nuke bot
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
window.deleteBot = async function (botId) {
|
||||||
|
// Safety check: prompt user to confirm
|
||||||
|
if (!confirm('Are you sure you want to delete this bot? This cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `Failed to delete bot (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Bot deleted successfully!');
|
||||||
|
// Reload the bots so it disappears from the list
|
||||||
|
fetchBots();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting bot:', err);
|
||||||
|
alert(`Error deleting bot: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------
|
||||||
|
* Content managment - Exposed as global functions
|
||||||
|
* -------------------------------------------------- */
|
||||||
|
// Called by content.html after we confirm the user is logged in
|
||||||
|
window.loadBotChoices = async function() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) return;
|
||||||
|
const res = await fetch(`${API_ENDPOINT}/api/bots`, {
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch bots');
|
||||||
|
}
|
||||||
|
const bots = await res.json();
|
||||||
|
const botSelect = document.getElementById('botSelect');
|
||||||
|
if (!botSelect) return;
|
||||||
|
|
||||||
|
botSelect.innerHTML = `<option value="">-- Select a Bot --</option>`;
|
||||||
|
bots.forEach(bot => {
|
||||||
|
botSelect.innerHTML += `<option value="${bot.id}">${bot.name} (ID: ${bot.id})</option>`;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading bot choices:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called when user picks a bot from the dropdown and clicks "Load Content"
|
||||||
|
window.loadBotContent = async function() {
|
||||||
|
const botSelect = document.getElementById('botSelect');
|
||||||
|
if (!botSelect) return;
|
||||||
|
const botId = botSelect.value;
|
||||||
|
if (!botId) {
|
||||||
|
alert('Please select a bot first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}`, {
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to list content');
|
||||||
|
}
|
||||||
|
const files = await res.json();
|
||||||
|
renderBotContent(botId, files);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading content:', err);
|
||||||
|
alert('Error loading content: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderBotContent(botId, files) {
|
||||||
|
const container = document.getElementById('contentArea');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
container.innerHTML = `
|
||||||
|
<h3>Content for Bot #${botId}</h3>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="file" id="uploadFileInput" />
|
||||||
|
<button class="btn btn-primary" onclick="uploadBotFile(${botId})">Upload</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
container.innerHTML += `<p>No files.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a simple list
|
||||||
|
let listHtml = `<ul class="list-group">`;
|
||||||
|
for (const f of files) {
|
||||||
|
listHtml += `
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
${f}
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteBotFile(${botId}, '${f}')">Delete</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
listHtml += `</ul>`;
|
||||||
|
|
||||||
|
container.innerHTML += listHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
window.uploadBotFile = async function(botId) {
|
||||||
|
const fileInput = document.getElementById('uploadFileInput');
|
||||||
|
if (!fileInput || !fileInput.files.length) {
|
||||||
|
return alert('No file selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': token },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
await res.json(); // read the body
|
||||||
|
|
||||||
|
alert('File uploaded!');
|
||||||
|
window.loadBotContent(botId); // refresh the list
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error uploading file:', err);
|
||||||
|
alert('Upload error: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
window.deleteBotFile = async function(botId, filename) {
|
||||||
|
if (!confirm(`Delete file "${filename}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
const res = await fetch(`${API_ENDPOINT}/api/content/${botId}/${filename}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': token }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to delete file');
|
||||||
|
}
|
||||||
|
alert('Deleted file');
|
||||||
|
window.loadBotContent(botId); // refresh
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting file:', err);
|
||||||
|
alert('Delete error: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If #logoutButton2 exists (used on content.html), attach event listener
|
||||||
|
const logoutButton2 = document.getElementById('logoutButton2');
|
||||||
|
if (logoutButton2) {
|
||||||
|
logoutButton2.addEventListener('click', logout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose checkAuth to global context for content.html
|
||||||
|
window.checkAuth = checkAuth;
|
||||||
});
|
});
|
253
web/content.html
Normal file
253
web/content.html
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
<!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 - Content Manager</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap & CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
|
|
||||||
|
<!-- nostr-tools ESM (attaches nip19 to window.nostrTools) -->
|
||||||
|
<script type="module">
|
||||||
|
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
||||||
|
window.nostrTools = { nip19 };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Main JS -->
|
||||||
|
<script src="/assets/js/main.js"></script>
|
||||||
|
|
||||||
|
<!-- Content Page JS -->
|
||||||
|
<script src="/assets/js/content.js"></script>
|
||||||
|
</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.18
|
||||||
|
a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41
|
||||||
|
a.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 - Content
|
||||||
|
</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="/">
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item" id="userInfoRow">
|
||||||
|
<span class="me-3">Logged in as: <span id="userPubkey"></span></span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" id="logoutButton2">Logout</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<!-- Bot Selection Section -->
|
||||||
|
<div class="bot-selection-container text-center">
|
||||||
|
<h3 class="mb-4">Select a Bot to Manage Content</h3>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<select id="botSelect" class="form-select">
|
||||||
|
<!-- Populated by loadBotChoices() in main.js -->
|
||||||
|
<option value="">-- Select a Bot --</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" id="loadContentBtn">Load Content</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Media Server Settings -->
|
||||||
|
<div class="row justify-content-center mt-3" id="mediaServerSettings">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Media Server Settings</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-2">
|
||||||
|
<label for="primaryServer" class="form-label">Primary Media Server</label>
|
||||||
|
<select id="primaryServer" class="form-select mb-2">
|
||||||
|
<option value="nip94">NIP-94 Server</option>
|
||||||
|
<option value="blossom">Blossom Server</option>
|
||||||
|
</select>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="primaryServerURL" class="form-label">Server URL</label>
|
||||||
|
<input type="text" id="primaryServerURL" class="form-control"
|
||||||
|
placeholder="https://your-nip94-server.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-2">
|
||||||
|
<label for="fallbackServer" class="form-label">Fallback Server</label>
|
||||||
|
<select id="fallbackServer" class="form-select mb-2">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="nip94">NIP-94 Server</option>
|
||||||
|
<option value="blossom">Blossom Server</option>
|
||||||
|
</select>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="fallbackServerURL" class="form-label">Fallback URL</label>
|
||||||
|
<input type="text" id="fallbackServerURL" class="form-control"
|
||||||
|
placeholder="https://your-fallback-server.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-2" id="saveMediaSettingsBtn">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content areas (only visible after bot selection) -->
|
||||||
|
<div id="contentContainer" class="d-none">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Content Upload Section (Left) -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>Content Files</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="contentArea">
|
||||||
|
<!-- Files will be listed here -->
|
||||||
|
<p>Select a bot and click "Load Content" to see files.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5>Upload New Content</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*">
|
||||||
|
<div id="uploadPreviewContainer" class="d-none">
|
||||||
|
<img id="uploadPreview" class="upload-preview img-fluid">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="uploadButton" disabled>Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Post Section (Right) -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>Create Manual Post</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Post Type Selection -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Post Type</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked>
|
||||||
|
<label class="form-check-label" for="postKind1">
|
||||||
|
Standard Post (kind: 1)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20">
|
||||||
|
<label class="form-check-label" for="postKind20">
|
||||||
|
<strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title Field (only for kind 20) -->
|
||||||
|
<div class="mb-3 d-none" id="titleField">
|
||||||
|
<label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)">
|
||||||
|
<small class="form-text text-muted">Required for picture posts (kind: 20)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Field -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="manualPostContent" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="manualPostContent" rows="4"
|
||||||
|
placeholder="Write your post content here..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtags Field -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="postHashtags" class="form-label">Hashtags</label>
|
||||||
|
<input type="text" class="form-control" id="postHashtags"
|
||||||
|
placeholder="comma separated: art, photography, etc">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Section - Enhanced for Kind 20 -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Media Attachment</h5>
|
||||||
|
<span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Upload Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Upload Image</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="file" id="postMediaInput" class="form-control" accept="image/*">
|
||||||
|
<button class="btn btn-secondary" type="button" id="quickUploadBtn">Upload</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Upload to attach an image to your post</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media URL Field (manually entered URL or populated after upload) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mediaUrlInput" class="form-label">Media URL</label>
|
||||||
|
<input type="text" class="form-control" id="mediaUrlInput"
|
||||||
|
placeholder="Enter media URL or upload an image">
|
||||||
|
<small class="form-text text-muted">For kind: 20 posts, an image URL is required (either here or in
|
||||||
|
the content)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alt Text Field (for accessibility) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mediaAltText" class="form-label">Alt Text</label>
|
||||||
|
<input type="text" class="form-control" id="mediaAltText"
|
||||||
|
placeholder="Describe the image for accessibility">
|
||||||
|
<small class="form-text text-muted">Recommended for image description (improves accessibility)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Preview -->
|
||||||
|
<div id="mediaPreviewContainer" class="d-none mt-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<img id="mediaPreview" class="upload-preview img-fluid mb-2">
|
||||||
|
<div id="mediaLinkContainer" class="media-link">
|
||||||
|
<small class="text-muted">Media will appear here after upload</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
118
web/index.html
118
web/index.html
@ -5,11 +5,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Nostr Poster</title>
|
<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="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/css/style.css">
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
|
<script type="module">
|
||||||
|
import { nip19 } from 'https://esm.sh/nostr-tools@1.10.0?bundle';
|
||||||
|
window.nostrTools = { nip19 };
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand" href="/">
|
||||||
@ -38,7 +47,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/#content">
|
<a class="nav-link" href="/content.html">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<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">
|
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="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
|
||||||
@ -172,37 +181,31 @@
|
|||||||
<div class="form-text">Optional. Used for verification.</div>
|
<div class="form-text">Optional. Used for verification.</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="border-secondary">
|
<hr class="border-secondary">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check">
|
<label for="keyOption" class="form-label">Key Option</label>
|
||||||
<input class="form-check-input" type="checkbox" id="generateKeypair" checked>
|
<select class="form-select" id="keyOption">
|
||||||
<label class="form-check-label" for="generateKeypair">
|
<option value="generate" selected>Generate new keypair</option>
|
||||||
Generate new keypair
|
<option value="import">Import existing NSEC key</option>
|
||||||
</label>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-text">Recommended. A unique Nostr identity will be created for your bot.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="keypair-input" class="d-none">
|
|
||||||
<div class="mb-3">
|
<div id="nsecKeyInput" class="mb-3" style="display: none;">
|
||||||
<label for="botPubkey" class="form-label">Public Key (hex)</label>
|
<label for="botNsecKey" class="form-label">NSEC Private Key</label>
|
||||||
<input type="text" class="form-control" id="botPubkey">
|
<div class="input-group">
|
||||||
</div>
|
<input type="password" class="form-control" id="botNsecKey" placeholder="nsec1...">
|
||||||
<div class="mb-3">
|
<button class="btn btn-secondary" type="button" id="toggleNsecKey">
|
||||||
<label for="botPrivkey" class="form-label">Private Key (hex)</label>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
<div class="input-group">
|
class="bi bi-eye" viewBox="0 0 16 16">
|
||||||
<input type="password" class="form-control" id="botPrivkey">
|
<path
|
||||||
<button class="btn btn-secondary" type="button" id="togglePrivkey">
|
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" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
<path
|
||||||
fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
|
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" />
|
||||||
<path
|
</svg>
|
||||||
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" />
|
</button>
|
||||||
<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>
|
||||||
|
<div class="form-text">Enter your private key in nsec format. Your public key will be
|
||||||
|
derived automatically.</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -221,8 +224,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<!-- Bot Settings Modal -->
|
||||||
<script src="/assets/js/main.js"></script>
|
<div class="modal fade" id="botSettingsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Bot Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="bot-settings-form">
|
||||||
|
<!-- Basic fields -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsName" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="botSettingsName">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsDisplayName" class="form-label">Display Name</label>
|
||||||
|
<input type="text" class="form-control" id="botSettingsDisplayName">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsBio" class="form-label">Bio</label>
|
||||||
|
<textarea class="form-control" id="botSettingsBio" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsNip05" class="form-label">NIP-05</label>
|
||||||
|
<input type="text" class="form-control" id="botSettingsNip05">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsZap" class="form-label">Zap Address</label>
|
||||||
|
<input type="text" class="form-control" id="botSettingsZap">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Post config fields -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsInterval" class="form-label">Posting Interval (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="botSettingsInterval" value="60">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="botSettingsHashtags" class="form-label">Hashtags (comma-separated)</label>
|
||||||
|
<input type="text" class="form-control" id="botSettingsHashtags">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="botSettingsSaveBtn" onclick="saveBotSettings()">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
Loading…
x
Reference in New Issue
Block a user