Compare commits

...

14 Commits

Author SHA1 Message Date
franzap
6daf0f4e5c Update zapstore.yaml 2025-05-20 23:15:31 -03:00
Alex Gleason
bfeaf0710f Allow --prompt-sec to be used with pipes 2025-05-03 07:27:37 -03:00
fiatjaf
e45b54ea62 fix nak mcp. 2025-04-10 16:59:56 -03:00
fiatjaf
35da063c30 precheck for validity of relay URLs and prevent unwanted crash otherwise. 2025-04-07 23:13:32 -03:00
fiatjaf
15aefe3df4 more examples on readme. 2025-04-03 22:17:30 -03:00
fiatjaf
55fd631787 fix term.GetSize() when piping. 2025-04-03 22:08:11 -03:00
fiatjaf
6f48c29d0f fix go-nostr dependency. 2025-04-03 21:31:12 -03:00
fiatjaf
703c186958 much more colors everywhere and everything is prettier. 2025-04-03 14:50:25 -03:00
fiatjaf
7ae2e686cb more colors. 2025-04-03 11:57:18 -03:00
fiatjaf
9547711e8d nice dynamic UI when connecting to relays, and go much faster concurrently. 2025-04-03 11:42:33 -03:00
fiatjaf
50119e21e6 fix nip73.ExternalPointer reference 2025-04-02 22:38:12 -03:00
fiatjaf
33f4272dd0 update go-nostr to maybe fix nip-60 wallets? 2025-04-02 22:37:59 -03:00
fiatjaf
7b6f387aad tags GetFirst() => Find() 2025-03-29 17:12:31 -03:00
fiatjaf
b1a03800e6 add fake fs command that doesn't work when compiling for windows but at least compiles. 2025-03-19 15:12:39 -03:00
19 changed files with 552 additions and 258 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
nak nak
mnt mnt
nak.exe

View File

@@ -229,6 +229,39 @@ type the password to decrypt your secret key: ********
~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"') ~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"')
``` ```
### mount Nostr as a FUSE filesystem and publish a note
```shell
~> nak fs --sec 01 ~/nostr
- mounting at /home/user/nostr... ok.
~> cd ~/nostr/npub1xxxxxx/notes/
~> echo "satellites are bad!" > new
pending note updated, timer reset.
- `touch publish` to publish immediately
- `rm new` to erase and cancel the publication.
~> touch publish
publishing now!
{"id":"f1cbfa6...","pubkey":"...","content":"satellites are bad!","sig":"..."}
publishing to 3 relays... offchain.pub: ok, nostr.wine: ok, pyramid.fiatjaf.com: ok
event published as f1cbfa6... and updated locally.
```
### list NIP-60 wallet tokens and send some
```shell
~> nak wallet tokens
91a10b6fc8bbe7ef2ad9ad0142871d80468b697716d9d2820902db304ff1165e 500 cashu.space
cac7f89f0611021984d92a7daca219e4cd1c9798950e50e952bba7cde1ac1337 1000 legend.lnbits.com
~> nak wallet send 100
cashuA1psxqyry8...
~> nak wallet pay lnbc1...
```
### upload and download files with blossom
```shell
~> nak blossom --server blossom.azzamo.net --sec 01 upload image.png
{"sha256":"38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf","url":"https://blossom.azzamo.net/38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf.png"}
~> nak blossom --server aegis.utxo.one download acc8ea43d4e6b706f68b249144364f446854b7f63ba1927371831c05dcf0256c -o downloaded.png
```
## contributing to this repository ## contributing to this repository
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`. Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`.

View File

@@ -11,10 +11,10 @@ import (
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46" "github.com/nbd-wtf/go-nostr/nip46"
"github.com/urfave/cli/v3"
) )
var bunker = &cli.Command{ var bunker = &cli.Command{
@@ -49,7 +49,7 @@ var bunker = &cli.Command{
qs := url.Values{} qs := url.Values{}
relayURLs := make([]string, 0, c.Args().Len()) relayURLs := make([]string, 0, c.Args().Len())
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 { if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, relayUrls, false) relays := connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)

View File

@@ -6,10 +6,10 @@ import (
"os" "os"
"strings" "strings"
"github.com/urfave/cli/v3"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip45" "github.com/nbd-wtf/go-nostr/nip45"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog" "github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
"github.com/urfave/cli/v3"
) )
var count = &cli.Command{ var count = &cli.Command{
@@ -70,10 +70,7 @@ var count = &cli.Command{
biggerUrlSize := 0 biggerUrlSize := 0
relayUrls := c.Args().Slice() relayUrls := c.Args().Slice()
if len(relayUrls) > 0 { if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, relays := connectToAllRelays(ctx, c, relayUrls, nil)
relayUrls,
false,
)
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)

12
dvm.go
View File

@@ -7,7 +7,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/fatih/color"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip90" "github.com/nbd-wtf/go-nostr/nip90"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -60,7 +59,7 @@ var dvm = &cli.Command{
Flags: flags, Flags: flags,
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
relayUrls := c.StringSlice("relay") relayUrls := c.StringSlice("relay")
relays := connectToAllRelays(ctx, relayUrls, false) relays := connectToAllRelays(ctx, c, relayUrls, nil)
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)
@@ -103,10 +102,7 @@ var dvm = &cli.Command{
log("- publishing job request... ") log("- publishing job request... ")
first := true first := true
for res := range sys.Pool.PublishMany(ctx, relayUrls, evt) { for res := range sys.Pool.PublishMany(ctx, relayUrls, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
@@ -114,9 +110,9 @@ var dvm = &cli.Command{
first = false first = false
if res.Error != nil { if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error) log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else { } else {
log("%s: ok", color.GreenString(cleanUrl)) log("%s: ok", colors.successf(cleanUrl))
} }
} }

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/fatih/color"
"github.com/mailru/easyjson" "github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip13" "github.com/nbd-wtf/go-nostr/nip13"
@@ -133,8 +134,17 @@ example:
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here // try to connect to the relays here
var relays []*nostr.Relay var relays []*nostr.Relay
// these are defaults, they will be replaced if we use the magic dynamic thing
logthis := func(relayUrl string, s string, args ...any) { log(s, args...) }
colorizethis := func(relayUrl string, colorize func(string, ...any) string) {}
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 { if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, relayUrls, false) relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.WithAuthHandler(func(ctx context.Context, authEvent nostr.RelayEvent) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
}),
)
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)
@@ -209,13 +219,19 @@ example:
} }
for _, etag := range c.StringSlice("e") { for _, etag := range c.StringSlice("e") {
tags = tags.AppendUnique([]string{"e", etag}) if tags.FindWithValue("e", etag) == nil {
tags = append(tags, nostr.Tag{"e", etag})
}
} }
for _, ptag := range c.StringSlice("p") { for _, ptag := range c.StringSlice("p") {
tags = tags.AppendUnique([]string{"p", ptag}) if tags.FindWithValue("p", ptag) == nil {
tags = append(tags, nostr.Tag{"p", ptag})
}
} }
for _, dtag := range c.StringSlice("d") { for _, dtag := range c.StringSlice("d") {
tags = tags.AppendUnique([]string{"d", dtag}) if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
}
} }
if len(tags) > 0 { if len(tags) > 0 {
for _, tag := range tags { for _, tag := range tags {
@@ -295,9 +311,73 @@ example:
successRelays := make([]string, 0, len(relays)) successRelays := make([]string, 0, len(relays))
if len(relays) > 0 { if len(relays) > 0 {
os.Stdout.Sync() os.Stdout.Sync()
if supportsDynamicMultilineMagic() {
// overcomplicated multiline rendering magic
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
urls := make([]string, len(relays))
lines := make([][][]byte, len(urls))
flush := func() {
for _, line := range lines {
for _, part := range line {
os.Stderr.Write(part)
}
os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
logthis = func(relayUrl, s string, args ...any) {
idx := slices.Index(urls, relayUrl)
lines[idx] = append(lines[idx], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizethis = func(relayUrl string, colorize func(string, ...any) string) {
cleanUrl, _ := strings.CutPrefix(relayUrl, "wss://")
idx := slices.Index(urls, relayUrl)
lines[idx][0] = []byte(fmt.Sprintf("publishing to %s... ", colorize(cleanUrl)))
render()
}
for i, relay := range relays {
urls[i] = relay.URL
lines[i] = make([][]byte, 1, 3)
colorizethis(relay.URL, color.CyanString)
}
render()
for res := range sys.Pool.PublishMany(ctx, urls, evt) {
if res.Error == nil {
colorizethis(res.RelayURL, colors.successf)
logthis(res.RelayURL, "success.")
successRelays = append(successRelays, res.RelayURL)
} else {
colorizethis(res.RelayURL, colors.errorf)
// in this case it's likely that the lowest-level error is the one that will be more helpful
low := unwrapAll(res.Error)
// hack for some messages such as from relay.westernbtc.com
msg := strings.ReplaceAll(low.Error(), evt.PubKey, "author")
// do not allow the message to overflow the term window
msg = clampMessage(msg, 20+len(res.RelayURL))
logthis(res.RelayURL, msg)
}
}
} else {
// normal dumb flow
for _, relay := range relays { for _, relay := range relays {
publish: publish:
log("publishing to %s... ", relay.URL) cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
log("publishing to %s... ", color.CyanString(cleanUrl))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
@@ -313,7 +393,8 @@ example:
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth { if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
// if the relay is requesting auth and we can auth, let's do it // if the relay is requesting auth and we can auth, let's do it
pk, _ := kr.GetPublicKey(ctx) pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk) npub, _ := nip19.EncodePublicKey(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error { if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
return kr.SignEvent(ctx, authEvent) return kr.SignEvent(ctx, authEvent)
}); err == nil { }); err == nil {
@@ -326,6 +407,8 @@ example:
} }
log("failed: %s\n", err) log("failed: %s\n", err)
} }
}
if len(successRelays) > 0 && c.Bool("nevent") { if len(successRelays) > 0 && c.Bool("nevent") {
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey) nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
log(nevent + "\n") log(nevent + "\n")

2
fs.go
View File

@@ -1,3 +1,5 @@
//go:build !windows
package main package main
import ( import (

20
fs_windows.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build windows
package main
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
var fsCmd = &cli.Command{
Name: "fs",
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
Description: `doesn't work on Windows.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("this doesn't work on Windows.")
},
}

8
go.mod
View File

@@ -17,9 +17,10 @@ require (
github.com/mailru/easyjson v0.9.0 github.com/mailru/easyjson v0.9.0
github.com/mark3labs/mcp-go v0.8.3 github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3 github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.51.6 github.com/nbd-wtf/go-nostr v0.51.8
github.com/urfave/cli/v3 v3.0.0-beta1 github.com/urfave/cli/v3 v3.0.0-beta1
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/term v0.30.0
) )
require ( require (
@@ -28,13 +29,13 @@ require (
github.com/btcsuite/btcd v0.24.2 // indirect github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // 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.13.1 // indirect github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/logex v1.1.10 // indirect github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.12 // indirect github.com/coder/websocket v1.8.13 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@@ -43,7 +44,6 @@ require (
github.com/fasthttp/websocket v1.5.12 // indirect github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect

16
go.sum
View File

@@ -33,8 +33,8 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
github.com/btcsuite/snappy-go v1.0.0/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/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/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
@@ -49,8 +49,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
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.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.13/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 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -102,8 +102,6 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k= github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ= github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0= github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
@@ -151,8 +149,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
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.51.6 h1:H51l39mp4dJztvtxjTNfNqNIxQyMoJMLSKt+1aGq3UU= github.com/nbd-wtf/go-nostr v0.51.8 h1:CIoS+YqChcm4e1L1rfMZ3/mIwTz4CwApM2qx7MHNzmE=
github.com/nbd-wtf/go-nostr v0.51.6/go.mod h1:so45r53GkZq+8vkdIW2jBlKEjdHyxEMrl/1g9tEoSFQ= github.com/nbd-wtf/go-nostr v0.51.8/go.mod h1:d6+DfvMWYG5pA3dmNMBJd6WCHVDDhkXbHqvfljf0Gzg=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 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.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.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -239,6 +237,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"iter" "iter"
"math/rand" "math/rand"
@@ -10,15 +11,19 @@ import (
"net/textproto" "net/textproto"
"net/url" "net/url"
"os" "os"
"runtime"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/sdk" "github.com/nbd-wtf/go-nostr/sdk"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"golang.org/x/term"
) )
var sys *sdk.System var sys *sdk.System
@@ -148,10 +153,19 @@ func normalizeAndValidateRelayURLs(wsurls []string) error {
func connectToAllRelays( func connectToAllRelays(
ctx context.Context, ctx context.Context,
c *cli.Command,
relayUrls []string, relayUrls []string,
forcePreAuth bool, preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error), // if this exists we will force preauth
opts ...nostr.PoolOption, opts ...nostr.PoolOption,
) []*nostr.Relay { ) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for _, url := range relayUrls {
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
sys.Pool = nostr.NewSimplePool(context.Background(), sys.Pool = nostr.NewSimplePool(context.Background(),
append(opts, append(opts,
nostr.WithEventMiddleware(sys.TrackEventHints), nostr.WithEventMiddleware(sys.TrackEventHints),
@@ -163,50 +177,174 @@ func connectToAllRelays(
) )
relays := make([]*nostr.Relay, 0, len(relayUrls)) relays := make([]*nostr.Relay, 0, len(relayUrls))
relayLoop:
for _, url := range relayUrls { if supportsDynamicMultilineMagic() {
log("connecting to %s... ", url) // overcomplicated multiline rendering magic
if relay, err := sys.Pool.EnsureRelay(url); err == nil { lines := make([][][]byte, len(relayUrls))
if forcePreAuth { flush := func() {
log("waiting for auth challenge... ") for _, line := range lines {
signer := opts[0].(nostr.WithAuthHandler) for _, part := range line {
time.Sleep(time.Millisecond * 200) os.Stderr.Write(part)
challengeWaitLoop:
for {
// beginhack
// here starts the biggest and ugliest hack of this codebase
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.GetFirst([]string{"challenge", ""})
if (*challengeTag)[1] == "" {
return fmt.Errorf("auth not received yet *****")
} }
return signer(ctx, nostr.RelayEvent{Event: authEvent, Relay: relay}) os.Stderr.Write([]byte{'\n'})
}
}
render := func() {
clearLines(len(lines))
flush()
}
flush()
wg := sync.WaitGroup{}
wg.Add(len(relayUrls))
for i, url := range relayUrls {
lines[i] = make([][]byte, 1, 2)
logthis := func(s string, args ...any) {
lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...)))
render()
}
colorizepreamble := func(c func(string, ...any) string) {
lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url)))
}
colorizepreamble(color.CyanString)
go func() {
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, colorizepreamble, logthis)
if relay != nil {
relays = append(relays, relay)
}
wg.Done()
}()
}
wg.Wait()
} else {
// simple flow
for _, url := range relayUrls {
log("connecting to %s... ", color.CyanString(url))
relay := connectToSingleRelay(ctx, c, url, preAuthSigner, nil, log)
if relay != nil {
relays = append(relays, relay)
}
log("\n")
}
}
return relays
}
func connectToSingleRelay(
ctx context.Context,
c *cli.Command,
url string,
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error),
colorizepreamble func(c func(string, ...any) string),
logthis func(s string, args ...any),
) *nostr.Relay {
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if preAuthSigner != nil {
if colorizepreamble != nil {
colorizepreamble(color.YellowString)
}
logthis("waiting for auth challenge... ")
time.Sleep(time.Millisecond * 200)
for range 5 {
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, nostr.RelayEvent{Event: authEvent, Relay: relay})
}); err == nil { }); err == nil {
// auth succeeded // auth succeeded
break challengeWaitLoop goto preauthSuccess
} else { } else {
// auth failed // auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") { if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting // it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second) time.Sleep(time.Second)
continue challengeWaitLoop continue
} else { } else {
// it failed for some other reason, so skip this relay // it failed for some other reason, so skip this relay
log(err.Error() + "\n") if colorizepreamble != nil {
continue relayLoop colorizepreamble(colors.errorf)
}
logthis(err.Error())
return nil
} }
} }
// endhack }
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
logthis("failed to get an AUTH challenge in enough time.")
return nil
}
preauthSuccess:
if colorizepreamble != nil {
colorizepreamble(colors.successf)
}
logthis("ok.")
return relay
} else {
if colorizepreamble != nil {
colorizepreamble(colors.errorf)
}
// if we're here that means we've failed to connect, this may be a huge message
// but we're likely to only be interested in the lowest level error (although we can leave space)
logthis(clampError(err, len(url)+12))
return nil
} }
} }
relays = append(relays, relay) func clearLines(lineCount int) {
log("ok.\n") for i := 0; i < lineCount; i++ {
} else { os.Stderr.Write([]byte("\033[0A\033[2K\r"))
log(err.Error() + "\n")
} }
} }
return relays
func supportsDynamicMultilineMagic() bool {
if runtime.GOOS == "windows" {
return false
}
if !term.IsTerminal(0) {
return false
}
width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil {
return false
}
if width < 110 {
return false
}
return true
}
func authSigner(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error) {
defer func() {
if err != nil {
cleanUrl, _ := strings.CutPrefix(authEvent.Relay.URL, "wss://")
log("%s auth failed: %s", colors.errorf(cleanUrl), err)
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth required, but --auth flag not given")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
npub, _ := nip19.EncodePublicKey(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
return kr.SignEvent(ctx, authEvent.Event)
} }
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context { func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
@@ -234,16 +372,51 @@ func leftPadKey(k string) string {
return strings.Repeat("0", 64-len(k)) + k return strings.Repeat("0", 64-len(k)) + k
} }
func unwrapAll(err error) error {
low := err
for n := low; n != nil; n = errors.Unwrap(low) {
low = n
}
return low
}
func clampMessage(msg string, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
}
return msg
}
func clampError(err error, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(0)
msg := err.Error()
if len(msg) > termSize-prefixAlreadyPrinted {
err = unwrapAll(err)
msg = clampMessage(err.Error(), prefixAlreadyPrinted)
}
return msg
}
var colors = struct { var colors = struct {
reset func(...any) (int, error) reset func(...any) (int, error)
italic func(...any) string italic func(...any) string
italicf func(string, ...any) string italicf func(string, ...any) string
bold func(...any) string bold func(...any) string
boldf func(string, ...any) string boldf func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{ }{
color.New(color.Reset).Print, color.New(color.Reset).Print,
color.New(color.Italic).Sprint, color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf, color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint, color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf, color.New(color.Bold).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,
color.New(color.Bold, color.FgHiGreen).Sprintf,
} }

View File

@@ -15,6 +15,7 @@ import (
"github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46" "github.com/nbd-wtf/go-nostr/nip46"
"github.com/nbd-wtf/go-nostr/nip49" "github.com/nbd-wtf/go-nostr/nip49"
"golang.org/x/term"
) )
var defaultKeyFlags = []cli.Flag{ var defaultKeyFlags = []cli.Flag{
@@ -84,9 +85,6 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
} }
if c.Bool("prompt-sec") { if c.Bool("prompt-sec") {
if isPiped() {
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
}
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil) sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("failed to get secret key: %w", err) return "", nil, fmt.Errorf("failed to get secret key: %w", err)
@@ -133,6 +131,35 @@ func promptDecrypt(ncryptsec string) (string, error) {
} }
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) { func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
if isPiped() {
// Use TTY method when stdin is piped
tty, err := os.Open("/dev/tty")
if err != nil {
return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe on this system (failed to open /dev/tty: %w), try again without --prompt-sec or provide the key via --sec or NOSTR_SECRET_KEY environment variable", err)
}
defer tty.Close()
for {
// Print the prompt to stderr so it's visible to the user
fmt.Fprintf(color.Error, color.YellowString(msg))
// Read password from TTY with masking
password, err := term.ReadPassword(int(tty.Fd()))
if err != nil {
return "", err
}
// Print newline after password input
fmt.Fprintln(color.Error)
answer := strings.TrimSpace(string(password))
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, nil
}
} else {
// Use normal readline method when stdin is not piped
config := &readline.Config{ config := &readline.Config{
Stdout: color.Error, Stdout: color.Error,
Prompt: color.YellowString(msg), Prompt: color.YellowString(msg),
@@ -159,3 +186,4 @@ func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, e
return answer, err return answer, err
} }
} }
}

113
mcp.go
View File

@@ -28,25 +28,13 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("publish_note", s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"), mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("relay", mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
mcp.Description("Relay to publish the note to"), mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
), mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
mcp.WithString("content", ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Required(), content := required[string](r, "content")
mcp.Description("Arbitrary string to be published"), mention, _ := optional[string](r, "mention")
), relay, _ := optional[string](r, "relay")
mcp.WithString("mention",
mcp.Required(),
mcp.Description("Nostr user's public key to be mentioned"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content, _ := request.Params.Arguments["content"].(string)
mention, _ := request.Params.Arguments["mention"].(string)
relayI, ok := request.Params.Arguments["relay"]
var relay string
if ok {
relay, _ = relayI.(string)
}
if mention != "" && !nostr.IsValidPublicKey(mention) { if mention != "" && !nostr.IsValidPublicKey(mention) {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -81,7 +69,9 @@ var mcpServer = &cli.Command{
} }
// extra relay specified // extra relay specified
if relay != "" {
relays = append(relays, relay) relays = append(relays, relay)
}
result := strings.Builder{} result := strings.Builder{}
result.WriteString( result.WriteString(
@@ -111,12 +101,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("resolve_nostr_uri", s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."), mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri", mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("URI to be resolved"), uri := required[string](r, "uri")
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri, _ := request.Params.Arguments["uri"].(string)
if strings.HasPrefix(uri, "nostr:") { if strings.HasPrefix(uri, "nostr:") {
uri = uri[6:] uri = uri[6:]
} }
@@ -159,12 +146,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("search_profile", s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"), mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("Name to be searched"), name := required[string](r, "name")
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}}) re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil { if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil return mcp.NewToolResultError("couldn't find anyone with that name"), nil
@@ -175,42 +159,24 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey", s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"), mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey", mcp.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("Public key of Nostr user we want to know the relay from where to read"), pubkey := required[string](r, "pubkey")
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, _ := request.Params.Arguments["pubkey"].(string)
res := sys.FetchOutboxRelays(ctx, pubkey, 1) res := sys.FetchOutboxRelays(ctx, pubkey, 1)
return mcp.NewToolResultText(res[0]), nil return mcp.NewToolResultText(res[0]), nil
}) })
s.AddTool(mcp.NewTool("read_events_from_relay", s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"), mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithNumber("kind", mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.Required(), mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.Description("event kind number to include in the 'kinds' field"), mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
), mcp.WithString("pubkey", mcp.Description("pubkey to include in the 'authors' field")),
mcp.WithString("pubkey", ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("pubkey to include in the 'authors' field"), relay := required[string](r, "relay")
), kind := int(required[float64](r, "kind"))
mcp.WithNumber("limit", limit := int(required[float64](r, "limit"))
mcp.Required(), pubkey, _ := optional[string](r, "pubkey")
mcp.Description("maximum number of events to query"),
),
mcp.WithString("relay",
mcp.Required(),
mcp.Description("relay URL to send the query to"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int)
pubkeyI, ok := request.Params.Arguments["pubkey"]
var pubkey string
if ok {
pubkey, _ = pubkeyI.(string)
}
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) { if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -242,3 +208,28 @@ var mcpServer = &cli.Command{
return server.ServeStdio(s) return server.ServeStdio(s)
}, },
} }
func required[T comparable](r mcp.CallToolRequest, p string) T {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero
}
if r.Params.Arguments[p].(T) == zero {
return zero
}
return r.Params.Arguments[p].(T)
}
func optional[T any](r mcp.CallToolRequest, p string) (T, bool) {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero, false
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, false
}
return r.Params.Arguments[p].(T), true
}

View File

@@ -348,11 +348,7 @@ func (e *EntityDir) handleWrite() {
success := false success := false
first := true first := true
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) { for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
} }

View File

@@ -18,6 +18,7 @@ import (
"github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip22" "github.com/nbd-wtf/go-nostr/nip22"
"github.com/nbd-wtf/go-nostr/nip27" "github.com/nbd-wtf/go-nostr/nip27"
"github.com/nbd-wtf/go-nostr/nip73"
"github.com/nbd-wtf/go-nostr/nip92" "github.com/nbd-wtf/go-nostr/nip92"
sdk "github.com/nbd-wtf/go-nostr/sdk" sdk "github.com/nbd-wtf/go-nostr/sdk"
) )
@@ -191,7 +192,7 @@ func (r *NostrRoot) CreateEventDir(
} }
} else if event.Kind == 1111 { } else if event.Kind == 1111 {
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil { if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
if xp, ok := pointer.(nostr.ExternalPointer); ok { if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@root", h.NewPersistentInode( h.AddChild("@root", h.NewPersistentInode(
r.ctx, r.ctx,
&fs.MemRegularFile{ &fs.MemRegularFile{
@@ -211,7 +212,7 @@ func (r *NostrRoot) CreateEventDir(
} }
} }
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil { if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
if xp, ok := pointer.(nostr.ExternalPointer); ok { if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@parent", h.NewPersistentInode( h.AddChild("@parent", h.NewPersistentInode(
r.ctx, r.ctx,
&fs.MemRegularFile{ &fs.MemRegularFile{

View File

@@ -179,11 +179,7 @@ func (n *ViewDir) publishNote() {
success := false success := false
first := true first := true
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) { for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
} }

48
req.go
View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/fatih/color"
"github.com/mailru/easyjson" "github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip77" "github.com/nbd-wtf/go-nostr/nip77"
@@ -76,35 +77,29 @@ example:
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
relayUrls := c.Args().Slice() relayUrls := c.Args().Slice()
if len(relayUrls) > 0 { if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, // this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
// connect to all relays we expect to use in this call in parallel
forcePreAuthSigner := authSigner
if !c.Bool("force-pre-auth") {
forcePreAuthSigner = nil
}
relays := connectToAllRelays(
ctx,
c,
relayUrls, relayUrls,
c.Bool("force-pre-auth"), forcePreAuthSigner,
nostr.WithAuthHandler( nostr.WithAuthHandler(func(ctx context.Context, authEvent nostr.RelayEvent) error {
func(ctx context.Context, authEvent nostr.RelayEvent) (err error) { return authSigner(ctx, c, func(s string, args ...any) {
defer func() { if strings.HasPrefix(s, "authenticating as") {
if err != nil { cleanUrl, _ := strings.CutPrefix(authEvent.Relay.URL, "wss://")
log("auth to %s failed: %s\n", s = "authenticating to " + color.CyanString(cleanUrl) + " as" + s[len("authenticating as"):]
(*authEvent.Tags.GetFirst([]string{"relay", ""}))[1], }
err, log(s+"\n", args...)
}, authEvent)
}),
) )
}
}()
if !c.Bool("auth") && !c.Bool("force-pre-auth") { // stop here already if all connections failed
return fmt.Errorf("auth not authorized")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk)
return kr.SignEvent(ctx, authEvent.Event)
},
),
)
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)
@@ -121,6 +116,7 @@ example:
}() }()
} }
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() { for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{} filter := nostr.Filter{}
if stdinFilter != "" { if stdinFilter != "" {

View File

@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/fatih/color"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip60" "github.com/nbd-wtf/go-nostr/nip60"
"github.com/nbd-wtf/go-nostr/nip61" "github.com/nbd-wtf/go-nostr/nip61"
@@ -60,20 +59,16 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
log("- saving kind:%d event (%s)... ", event.Kind, desc) log("- saving kind:%d event (%s)... ", event.Kind, desc)
first := true first := true
for res := range sys.Pool.PublishMany(ctx, relays, event) { for res := range sys.Pool.PublishMany(ctx, relays, event) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
} }
first = false first = false
if res.Error != nil { if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error) log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else { } else {
log("%s: ok", color.GreenString(cleanUrl)) log("%s: ok", colors.successf(cleanUrl))
} }
} }
log("\n") log("\n")
@@ -376,19 +371,15 @@ var wallet = &cli.Command{
log("- publishing nutzap... ") log("- publishing nutzap... ")
first := true first := true
for res := range results { for res := range results {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
} }
first = false first = false
if res.Error != nil { if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error) log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else { } else {
log("%s: ok", color.GreenString(cleanUrl)) log("%s: ok", colors.successf(cleanUrl))
} }
} }
@@ -463,10 +454,7 @@ var wallet = &cli.Command{
log("- saving kind:10019 event... ") log("- saving kind:10019 event... ")
first := true first := true
for res := range sys.Pool.PublishMany(ctx, relays, evt) { for res := range sys.Pool.PublishMany(ctx, relays, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://") cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first { if !first {
log(", ") log(", ")
@@ -474,9 +462,9 @@ var wallet = &cli.Command{
first = false first = false
if res.Error != nil { if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error) log("%s: %s", colors.errorf(cleanUrl), res.Error)
} else { } else {
log("%s: ok", color.GreenString(cleanUrl)) log("%s: ok", colors.successf(cleanUrl))
} }
} }

View File

@@ -1,14 +1,7 @@
nak:
cli:
name: nak
summary: a command line tool for doing all things nostr
repository: https://github.com/fiatjaf/nak repository: https://github.com/fiatjaf/nak
artifacts: assets:
nak-v%v-darwin-arm64: - nak-v\d+\.\d+\.\d+-darwin-arm64
platforms: [darwin-arm64] - nak-v\d+\.\d+\.\d+-linux-amd64
nak-v%v-darwin-amd64: - nak-v\d+\.\d+\.\d+-linux-arm64
platforms: [darwin-x86_64] remote_metadata:
nak-v%v-linux-arm64: - github
platforms: [linux-aarch64]
nak-v%v-linux-amd64:
platforms: [linux-x86_64]