Compare commits

...
14 changed files with 951 additions and 86 deletions
+64 -3
View File
@@ -107,7 +107,7 @@ ok.
### sign an event collaboratively with multiple parties using musig2
```shell
~> nak event --sec 1234 -k 1 -c 'hello from a combined key' --musig 2
~> nak event --sec 1234 -k 'text note' -c 'hello from a combined key' --musig 2
the following code should be saved secretly until the next step an included with --musig-nonce-secret:
QebOT03ERmV7km22CqEqBPFmzAkgxQzGGbR7Si8yIZCBrd1N9A3LKwGLO71kbgXZ9EYFKpjiwun4u0mj5Tq6vwM3pK7x+EI8oHbkt9majKv/QN24Ix8qnwEIHxXX+mXBug==
@@ -145,6 +145,30 @@ type the password to decrypt your secret key: **********
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
```
### display profile information for any npub, nip05 or hex key
```shell
~> nak profile f7z.io
pubkey (hex): fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52
npub: npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
profile uri: nostr:nprofile1qqs04xzt6ldm9qhs0ctw0t58kf4z57umjzmjg6jywu0seadwtqqc75spr9mhxue69uhhq7tjv9kkjepwve5kzar2v9nzucm0d5q3qamnwvaz7te3xsczue3h0ghxjmcppemhxue69uhhytnxxaazu6t0axr23s
name: PABLOF7z
display_name: PABLOF7z
about: Magical Other Stuff Maximalist.
picture: https://m.primal.net/KwlG.jpg
banner: https://24242.io/1dc3a4a8bb2626551d4cc91e93f5b2e6ef87b1931b9db9bf4ac72dfe61c7a45b.png
website: https://pablof7z.com
nip05: _@f7z.io (verified)
lud16: pablof7z@primal.net
relays:
wss://relay.damus.io (read)
wss://relay.primal.net (read)
wss://pyramid.fiatjaf.com (write)
wss://140.f7z.io (write)
wss://r.f7z.io (read/write)
follows: 1051
```
### sign an event using a bunker provider (amber, promenade etc)
```shell
~> export NOSTR_CLIENT_KEY="$(nak key generate)"
@@ -343,7 +367,7 @@ echo "#surely you're joking, mr npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn6
### record and publish an audio note (yakbak, nostur etc) signed from a bunker
```shell
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 1222 --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blossom.primal.net upload | jq -rc '{content: .url}' | nak event -k 'voice message' --sec 'bunker://urlgoeshere' pyramid.fiatjaf.com nostr.wine
```
### gift-wrap an event to a recipient and publish it somewhere
@@ -353,7 +377,7 @@ ffmpeg -f alsa -i default -f webm -t 00:00:03 pipe:1 | nak blossom --server blos
### download a gift-wrap event and unwrap it
```shell
~> nak req -p <my-public-key> -k 1059 relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
~> nak req -p <my-public-key> -k 'giftwrap' relay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>
```
### sync events between two relays using negentropy
@@ -472,3 +496,40 @@ gitnostr.com... ok.
~> nak group chat "<relay>'<id>"
~> nak group chat send "<relay>'<id>" "<message>"
```
### figure out what is a given kind
```shell
~> nak kind 10050 | jq .description
"Relay list to receive DMs"
~> nak kind 'fav relays' | jq
{
"kind": 10012,
"description": "Favorite relays list",
"in_use": true,
"content": {
"type": "free"
},
"multiple": [
"relay"
],
"tags": [
{
"name": "relay",
"next": {
"type": "relay",
"required": true
}
},
{
"name": "a",
"next": {
"type": "addr",
"required": true,
"next": {
"type": "relay"
}
}
}
]
}
```
+13 -2
View File
@@ -8,6 +8,7 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
@@ -290,7 +291,7 @@ var encode = &cli.Command{
Usage: "pubkey of the naddr author",
Aliases: []string{"author", "a", "p"},
},
&cli.IntFlag{
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
@@ -305,12 +306,22 @@ var encode = &cli.Command{
Usage: "automatically appends outbox relays to the code",
Value: 3,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
},
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getEncodeSubcommandInput(c.Args(), true) {
pubkey := getPubKey(c, "pubkey")
kind := nostr.Kind(c.Int("kind"))
kind := getKind(c, "kind")
d := c.String("identifier")
relays := c.StringSlice("relay")
+15 -4
View File
@@ -13,6 +13,7 @@ import (
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nip13"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/schema"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
@@ -104,10 +105,10 @@ example:
DefaultText: "false, will only use manually-specified relays",
Category: CATEGORY_EXTRAS,
},
&cli.UintFlag{
&KindFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "event kind",
Usage: "event kind number or name",
DefaultText: "1",
Value: 0,
Category: CATEGORY_EVENT_FIELDS,
@@ -165,6 +166,16 @@ example:
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
),
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
@@ -197,8 +208,8 @@ example:
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = nostr.Kind(kind)
if c.IsSet("kind") {
evt.Kind = getKind(c, "kind")
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
+52
View File
@@ -289,3 +289,55 @@ type (
func getIDSlice(cmd *cli.Command, name string) []nostr.ID {
return cmd.Value(name).([]nostr.ID)
}
//
//
//
type (
KindFlag = cli.FlagBase[nostr.Kind, struct{}, kindValue]
)
type kindValue struct {
kind nostr.Kind
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.Kind, struct{}] = kindValue{}
func (t kindValue) Create(val nostr.Kind, p *nostr.Kind, c struct{}) cli.Value {
*p = val
return &kindValue{
kind: val,
}
}
func (t kindValue) ToString(b nostr.Kind) string { return fmt.Sprintf("%d", b) }
func (t *kindValue) Set(value string) error {
k, err := stringToKind(value)
t.kind = k
t.hasBeenSet = true
return err
}
func (t *kindValue) String() string { return fmt.Sprintf("%#v", t.kind) }
func (t *kindValue) Value() nostr.Kind { return t.kind }
func (t *kindValue) Get() any { return t.kind }
func getKind(cmd *cli.Command, name string) nostr.Kind {
return cmd.Value(name).(nostr.Kind)
}
//
//
//
type (
kindSlice = cli.SliceBase[nostr.Kind, struct{}, kindValue]
KindSliceFlag = cli.FlagBase[[]nostr.Kind, struct{}, kindSlice]
)
func getKindSlice(cmd *cli.Command, name string) []nostr.Kind {
return cmd.Value(name).([]nostr.Kind)
}
+2 -1
View File
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86
fiatjaf.com/nostr v0.0.0-20260603164911-395c9609550b
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -77,6 +77,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
+12 -2
View File
@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86 h1:p3HnX1UDT/CfiMvTc4yTcxHQm08ri7DM32P1uKkFNKg=
fiatjaf.com/nostr v0.0.0-20260602223326-015842e96d86/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
fiatjaf.com/nostr v0.0.0-20260603164911-395c9609550b h1:uFCYH+AyyhyqL9a04BEJQ/vEZ9QP793mJWXUkIHKxWc=
fiatjaf.com/nostr v0.0.0-20260603164911-395c9609550b/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
@@ -181,6 +181,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
@@ -300,6 +302,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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=
@@ -308,11 +311,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -329,11 +334,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -341,11 +348,14 @@ 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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
+12 -48
View File
@@ -276,68 +276,32 @@ var group = &cli.Command{
return err
}
// stored events arrive newest-first before EOSE, so we buffer them
// from the end and print them in chronological order once EOSE
// arrives; live events are printed as they come.
eosed := false
var mu sync.Mutex
messages := make([]struct {
message string
rendered bool
}, 200)
messages := make([]string, 200)
base := len(messages)
// must be called with mu held
tryRender := func(i int) {
// if all messages before these are loaded we can render this,
// otherwise we render whatever we can and stop
for m, msg := range messages[base:] {
if msg.rendered {
continue
}
if msg.message == "" {
break
}
messages[base+m].rendered = true
stdout(msg.message)
}
}
for {
select {
case evt := <-sub.Events:
mu.Lock()
var i int
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
line := color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
if eosed {
i = len(messages)
messages = append(messages, struct {
message string
rendered bool
}{})
stdout(line)
} else if base > 0 {
base--
i = base
} else {
// pre-EOSE buffer is full (relay returned more than the limit); drop.
mu.Unlock()
continue
messages[base] = line
}
mu.Unlock()
go func() {
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
line := color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
mu.Lock()
messages[i].message = line
if eosed {
tryRender(i)
}
mu.Unlock()
}()
// else: pre-EOSE buffer is full (relay returned more than the limit); drop.
case reason := <-sub.ClosedReason:
stdout("closed:" + color.YellowString(reason))
case <-sub.EndOfStoredEvents:
mu.Lock()
eosed = true
tryRender(len(messages) - 1)
mu.Unlock()
for _, msg := range messages[base:] {
stdout(msg)
}
case <-sub.Context.Done():
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
}
+62
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -22,10 +23,12 @@ import (
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/schema"
"fiatjaf.com/nostr/sdk"
"github.com/chzyer/readline"
"github.com/fatih/color"
jsoniter "github.com/json-iterator/go"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/mattn/go-isatty"
"github.com/mattn/go-tty/v2"
"github.com/urfave/cli/v3"
@@ -610,6 +613,65 @@ func clampWithEllipsis(s string, size int) string {
return s[0:size-1] + "…"
}
var (
schemaURI string
fetchSchemaOnce sync.Once
schemaCache schema.Schema
schemaErrCache error
)
func getSchema() (schema.Schema, error) {
fetchSchemaOnce.Do(func() {
if strings.HasPrefix(schemaURI, "http") {
schemaCache, schemaErrCache = schema.FetchSchemaFromURL(schemaURI)
} else {
schemaCache, schemaErrCache = schema.NewSchemaFromFile(schemaURI)
}
})
return schemaCache, schemaErrCache
}
func stringToKind(value string) (nostr.Kind, error) {
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
return nostr.Kind(n), nil
}
// find kind from name
sch, err := getSchema()
if err != nil {
return 0, err
}
fuzzyWords := make([]string, 0, len(sch.Kinds))
fuzzyIndexes := make([]string, 0, len(sch.Kinds))
// exact match
for k, ks := range sch.Kinds {
fuzzyWords = append(fuzzyWords, ks.Description)
fuzzyIndexes = append(fuzzyIndexes, k)
if strings.EqualFold(ks.Description, value) {
return ks.Kind, nil
}
}
// fuzzy match
result := fuzzy.RankFindNormalizedFold(value, fuzzyWords)
bestDesc := "<none>"
bestDist := "-"
if len(result) > 0 {
if bd := result[0].Distance; bd < 26 {
return sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Kind, nil
} else {
bestDesc = sch.Kinds[fuzzyIndexes[result[0].OriginalIndex]].Description
bestDist = strconv.Itoa(bd)
}
}
return 0, fmt.Errorf("unknown kind: %q (closest: %q, distance: %s)", value, bestDesc, bestDist)
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"context"
"fmt"
"strconv"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
"github.com/urfave/cli/v3"
)
var kindCmd = &cli.Command{
Name: "kind",
Usage: "look up information about a Nostr event kind",
Description: `takes a kind number or a kind name and prints information about that kind from the registry of kinds schema.
example:
nak kind 1
nak kind "text note"
nak kind "git meta"
nak kind "fav relays"
nak kind 30023`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("requires exactly one argument: kind number or name")
}
input := c.Args().First()
// resolve input to a kind number
var k nostr.Kind
if n, err := strconv.ParseUint(input, 10, 16); err == nil && n >= 0 {
k = nostr.Kind(n)
} else {
resolved, err := stringToKind(input)
if err != nil {
return fmt.Errorf("failed to resolve kind: %w", err)
}
k = resolved
}
sch, err := getSchema()
if err != nil {
return err
}
ks := sch.Kinds[strconv.Itoa(int(k.Num()))]
j, _ := json.MarshalIndent(ks, "", " ")
stdout(string(j))
return nil
},
}
+2
View File
@@ -33,6 +33,7 @@ var app = &cli.Command{
decode,
encode,
key,
kindCmd,
verify,
relay,
admin,
@@ -52,6 +53,7 @@ var app = &cli.Command{
nsite,
git,
group,
podcast,
nip,
syncCmd,
spell,
+2 -2
View File
@@ -198,9 +198,9 @@ var nsite = &cli.Command{
stdout(evt.String())
if identifier == "" {
stdout(nip19.EncodeNpub(pk))
log(nip19.EncodeNpub(pk) + "\n")
} else {
stdout(nip5a.PubKeyToBase36(pk) + identifier)
log(nip5a.PubKeyToBase36(pk) + identifier + "\n")
}
return publishFlow(ctx, c, kr, evt, relays)
+626
View File
@@ -0,0 +1,626 @@
package main
import (
"context"
"fmt"
"os/exec"
"runtime"
"slices"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
type podcastInfo struct {
PubKey nostr.PubKey
Title string
Image string
Relays []string
Metadata nostr.Event
}
var podcast = &cli.Command{
Name: "podcast",
Usage: "play podcasts from Nostr",
Commands: []*cli.Command{
{
Name: "play",
Usage: "play latest episode from podcast or specific episode",
ArgsUsage: "<pubkey|nip05|npub|nprofile|nevent>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.StringFlag{
Name: "player",
Usage: "player command to use, defaults to auto-detect",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast or episode target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
episode, err := resolvePodcastEpisode(ctx, target, relays)
if err != nil {
return err
}
if err := playPodcastEpisode(c.String("player"), episode); err != nil {
return err
}
}
return nil
},
},
{
Name: "info",
Usage: "display podcast metadata",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
printPodcastInfo(p)
}
}
return nil
},
},
{
Name: "list",
Usage: "list podcast episodes",
ArgsUsage: "<pubkey|nip05|npub|nprofile>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "maximum number of episodes to fetch",
Value: 50,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if target == "" {
return fmt.Errorf("missing podcast target")
}
relays := c.StringSlice("relay")
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
podcasts, err := resolvePodcastsForPubkey(ctx, target, relays)
if err != nil {
return err
}
for _, p := range podcasts {
if err := printPodcastEpisodeList(ctx, p, int(c.Int("limit"))); err != nil {
return err
}
}
}
return nil
},
},
},
}
func resolvePodcastEpisode(ctx context.Context, target string, relayHints []string) (*nostr.Event, error) {
target = strings.TrimSpace(strings.TrimPrefix(target, "nostr:"))
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "nevent":
pointer := value.(nostr.EventPointer)
return fetchSpecificPodcastEpisode(ctx, pointer, relayHints)
case "note":
pointer := nostr.EventPointer{ID: value.(nostr.ID)}
return fetchSpecificPodcastEpisode(ctx, pointer, relayHints)
}
}
if pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target); err == nil {
return fetchLatestEpisodeForPodcastSelection(ctx, pk, nostr.AppendUnique(relayHints, extraRelays...))
}
return nil, fmt.Errorf("invalid podcast target %q", target)
}
func resolvePodcastPubKeyTarget(ctx context.Context, target string) (nostr.PubKey, []string, error) {
if strings.TrimSpace(target) == "" {
return nostr.ZeroPK, nil, fmt.Errorf("missing pubkey target")
}
if strings.HasPrefix(target, "nostr:") {
target = strings.TrimPrefix(target, "nostr:")
}
if prefix, value, err := nip19.Decode(target); err == nil {
switch prefix {
case "npub":
return value.(nostr.PubKey), nil, nil
case "nprofile":
profile := value.(nostr.ProfilePointer)
return profile.PublicKey, profile.Relays, nil
}
}
if strings.Contains(target, "@") {
pp, err := nip05.QueryIdentifier(ctx, target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pp.PublicKey, pp.Relays, nil
}
pk, err := nostr.PubKeyFromHex(target)
if err != nil {
return nostr.ZeroPK, nil, err
}
return pk, nil, nil
}
func fetchSpecificPodcastEpisode(ctx context.Context, pointer nostr.EventPointer, relayHints []string) (*nostr.Event, error) {
relays := slices.Clone(relayHints)
relays = nostr.AppendUnique(relays, pointer.Relays...)
if pointer.Author != nostr.ZeroPK {
relays = nostr.AppendUnique(relays, sys.FetchOutboxRelays(ctx, pointer.Author, 3)...)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for episode")
}
res := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{IDs: []nostr.ID{pointer.ID}}, nostr.SubscriptionOptions{Label: "nak-podcast"})
if res == nil {
return nil, fmt.Errorf("podcast episode not found")
}
if res.Event.Kind != 54 {
return nil, fmt.Errorf("event is not podcast episode: expected kind 54, got %d", res.Event.Kind)
}
return &res.Event, nil
}
func fetchLatestEpisodeForPodcastSelection(ctx context.Context, pk nostr.PubKey, relayHints []string) (*nostr.Event, error) {
choices, err := discoverPodcasts(ctx, pk, relayHints)
if err != nil {
return nil, err
}
chosen := choices[0]
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
for i, label := range labels {
if label == selected {
chosen = choices[i]
break
}
}
}
episode, err := fetchLatestPodcastEpisode(ctx, chosen)
if err != nil {
return nil, err
}
return episode, nil
}
func discoverPodcasts(ctx context.Context, pk nostr.PubKey, relayHints []string) ([]podcastInfo, error) {
choices := make([]podcastInfo, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
if direct, ok := fetchPodcastMetadata(ctx, pk, relayHints); ok {
choices = append(choices, direct)
seen[pk] = struct{}{}
}
authoredRelays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
for _, candidatePubKey := range fetchAuthoredPodcastPubKeys(ctx, pk, authoredRelays) {
if _, ok := seen[candidatePubKey]; ok {
continue
}
meta, ok := fetchPodcastMetadata(ctx, candidatePubKey, relayHints)
if !ok {
continue
}
if !podcastClaimsAuthor(meta.Metadata, pk) {
continue
}
choices = append(choices, meta)
seen[candidatePubKey] = struct{}{}
}
if len(choices) == 0 {
return nil, fmt.Errorf("no podcasts found for %s", nip19.EncodeNpub(pk))
}
return choices, nil
}
func fetchPodcastMetadata(ctx context.Context, pk nostr.PubKey, relayHints []string) (podcastInfo, bool) {
relays := nostr.AppendUnique(slices.Clone(relayHints), sys.FetchOutboxRelays(ctx, pk, 3)...)
if len(relays) == 0 {
return podcastInfo{}, false
}
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{10154},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
return podcastInfo{}, false
}
return podcastInfo{
PubKey: pk,
Title: podcastTagValue(evt, "title"),
Image: podcastTagValue(evt, "image"),
Relays: relays,
Metadata: evt,
}, true
}
func fetchAuthoredPodcastPubKeys(ctx context.Context, pk nostr.PubKey, relays []string) []nostr.PubKey {
if len(relays) == 0 {
return nil
}
found := make([]nostr.PubKey, 0, 4)
seen := make(map[nostr.PubKey]struct{}, 4)
for _, kind := range []nostr.Kind{10064, 10164} {
res := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{kind},
Authors: []nostr.PubKey{pk},
}, nostr.SubscriptionOptions{Label: "nak-podcast"})
evt, ok := res.Load(nostr.ReplaceableKey{PubKey: pk, D: ""})
if !ok {
continue
}
for _, tag := range evt.Tags {
if len(tag) < 2 || tag[0] != "p" {
continue
}
podcastPK, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
if _, ok := seen[podcastPK]; ok {
continue
}
seen[podcastPK] = struct{}{}
found = append(found, podcastPK)
}
}
return found
}
func podcastClaimsAuthor(metadata nostr.Event, author nostr.PubKey) bool {
for _, tag := range metadata.Tags {
if len(tag) >= 2 && tag[0] == "p" && tag[1] == author.Hex() {
return true
}
}
return false
}
func fetchLatestPodcastEpisode(ctx context.Context, podcast podcastInfo) (*nostr.Event, error) {
relays := podcast.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, podcast.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(podcast.PubKey))
}
var latest *nostr.Event
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{podcast.PubKey},
Limit: 20,
}, nostr.SubscriptionOptions{Label: "nak-podcast"}) {
if latest == nil || ie.Event.CreatedAt > latest.CreatedAt {
event := ie.Event
latest = &event
}
}
if latest == nil {
return nil, fmt.Errorf("no episodes found for %s", podcastLabel(podcast))
}
return latest, nil
}
func playPodcastEpisode(player string, episode *nostr.Event) error {
audioURL := firstPodcastAudioURL(*episode)
if audioURL == "" {
return fmt.Errorf("podcast episode has no audio tag")
}
title := podcastTagValue(*episode, "title")
if title == "" {
title = episode.ID.Hex()
}
stdout(colors.bold("playing:"), color.HiBlueString(title))
stdout(colors.bold("audio:"), color.HiCyanString(audioURL))
cmd, err := buildPodcastPlayerCommand(player, audioURL)
if err != nil {
return err
}
cmd.Stdin = nil
cmd.Stdout = color.Output
cmd.Stderr = color.Error
return cmd.Run()
}
func buildPodcastPlayerCommand(player string, audioURL string) (*exec.Cmd, error) {
if strings.TrimSpace(player) != "" {
parts := strings.Fields(player)
if len(parts) == 0 {
return nil, fmt.Errorf("invalid player command %q", player)
}
return exec.Command(parts[0], append(parts[1:], audioURL)...), nil
}
for _, candidate := range podcastPlayerCandidates() {
if _, err := exec.LookPath(candidate.name); err == nil {
return exec.Command(candidate.name, append(candidate.args, audioURL)...), nil
}
}
return nil, fmt.Errorf("no media player found; install mpv, vlc, ffplay or pass --player")
}
func podcastPlayerCandidates() []struct {
name string
args []string
} {
if runtime.GOOS == "darwin" {
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"open", nil},
}
}
if runtime.GOOS == "windows" {
return []struct {
name string
args []string
}{
{"mpv.exe", nil},
{"vlc.exe", nil},
{"ffplay.exe", []string{"-nodisp", "-autoexit"}},
{"cmd", []string{"/c", "start"}},
}
}
return []struct {
name string
args []string
}{
{"mpv", nil},
{"vlc", nil},
{"ffplay", []string{"-nodisp", "-autoexit"}},
{"xdg-open", nil},
}
}
func firstPodcastAudioURL(evt nostr.Event) string {
for _, tag := range evt.Tags {
if len(tag) >= 2 && tag[0] == "audio" {
return tag[1]
}
}
return ""
}
func podcastTagValue(evt nostr.Event, name string) string {
if tag := evt.Tags.Find(name); len(tag) >= 2 {
return tag[1]
}
return ""
}
func podcastLabel(info podcastInfo) string {
title := strings.TrimSpace(info.Title)
if title == "" {
title = nip19.EncodeNpub(info.PubKey)
} else {
title += " (" + nip19.EncodeNpub(info.PubKey) + ")"
}
return title
}
func resolvePodcastsForPubkey(ctx context.Context, target string, relayHints []string) ([]podcastInfo, error) {
pk, extraRelays, err := resolvePodcastPubKeyTarget(ctx, target)
if err != nil {
return nil, err
}
allRelays := nostr.AppendUnique(relayHints, extraRelays...)
choices, err := discoverPodcasts(ctx, pk, allRelays)
if err != nil {
return nil, err
}
if len(choices) > 1 {
labels := make([]string, len(choices))
for i, item := range choices {
labels[i] = podcastLabel(item)
}
var selected string
if err := survey.AskOne(&survey.Select{
Message: "choose podcast",
Options: labels,
PageSize: 12,
}, &selected); err != nil {
return nil, err
}
filtered := make([]podcastInfo, 0, 1)
for i, label := range labels {
if label == selected {
filtered = append(filtered, choices[i])
break
}
}
return filtered, nil
}
return choices, nil
}
func printPodcastInfo(p podcastInfo) {
stdout(colors.bold("title:"), color.HiBlueString(p.Title))
stdout(colors.bold("pubkey:"), color.HiCyanString(nip19.EncodeNpub(p.PubKey)))
if desc := podcastTagValue(p.Metadata, "description"); desc != "" {
stdout(colors.bold("description:"), desc)
}
if image := podcastTagValue(p.Metadata, "image"); image != "" {
stdout(colors.bold("image:"), color.HiCyanString(image))
}
if website := podcastTagValue(p.Metadata, "website"); website != "" {
stdout(colors.bold("website:"), color.HiCyanString(website))
}
authors := make([]string, 0, 2)
for _, tag := range p.Metadata.Tags {
if len(tag) >= 3 && tag[0] == "p" {
role := ""
if len(tag) >= 3 {
role = tag[2]
}
pk, err := nostr.PubKeyFromHex(tag[1])
if err != nil {
continue
}
authors = append(authors, nip19.EncodeNpub(pk)+" "+role)
}
}
if len(authors) > 0 {
stdout(colors.bold("authors:"), strings.Join(authors, ", "))
}
stdout("")
}
func printPodcastEpisodeList(ctx context.Context, p podcastInfo, limit int) error {
episodes, err := fetchPodcastEpisodes(ctx, p, limit)
if err != nil {
return err
}
if len(episodes) == 0 {
stdout(color.YellowString("no episodes found for %s"), p.Title)
return nil
}
stdout(color.CyanString("episodes for %s:"), p.Title)
for _, ep := range episodes {
title := podcastTagValue(ep, "title")
if title == "" {
title = ep.ID.Hex()[:16]
}
date := ep.CreatedAt.Time().Format("2006-01-02")
desc := ep.Content
if len(desc) > 100 {
desc = desc[:100] + "..."
}
stdout(fmt.Sprintf("%s %s %s", date, title, desc))
}
return nil
}
func fetchPodcastEpisodes(ctx context.Context, p podcastInfo, limit int) ([]nostr.Event, error) {
relays := p.Relays
if len(relays) == 0 {
relays = sys.FetchOutboxRelays(ctx, p.PubKey, 3)
}
if len(relays) == 0 {
return nil, fmt.Errorf("no relay hints found for podcast %s", nip19.EncodeNpub(p.PubKey))
}
episodes := make([]nostr.Event, 0, limit)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{54},
Authors: []nostr.PubKey{p.PubKey},
Limit: limit,
}, nostr.SubscriptionOptions{Label: "nak-podcast-list"}) {
episodes = append(episodes, ie.Event)
}
return episodes, nil
}
+15 -4
View File
@@ -17,6 +17,7 @@ import (
"fiatjaf.com/nostr/eventstore/wrappers"
"fiatjaf.com/nostr/nip42"
"fiatjaf.com/nostr/nip77"
"fiatjaf.com/nostr/schema"
"github.com/fatih/color"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
@@ -503,10 +504,10 @@ var reqFilterFlags = []cli.Flag{
Usage: "only accept events with these ids",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
&KindSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Usage: "only accept events with these kind numbers or kind names",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
@@ -558,6 +559,16 @@ var reqFilterFlags = []cli.Flag{
Usage: "a nip50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
// hidden
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
Hidden: true,
},
}
type flagTag struct {
@@ -587,8 +598,8 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if ids := getIDSlice(c, "id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, nostr.Kind(kind64))
if kinds := getKindSlice(c, "kind"); len(kinds) > 0 {
filter.Kinds = append(filter.Kinds, kinds...)
}
if search := c.String("search"); search != "" {
filter.Search = search
+10 -20
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/schema"
@@ -22,29 +21,21 @@ nak event -k 1 -p not_a_pubkey | nak validate
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: "https://raw.githubusercontent.com/nostr-protocol/registry-of-kinds/refs/heads/master/schema.yaml",
TakesFile: true,
Name: "schema",
Usage: "url to download the YAML schema from, or path to the file",
Value: schema.DefaultSchemaURL,
TakesFile: true,
Destination: &schemaURI,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
var validator schema.Validator
if schemaURL := c.String("schema"); strings.HasPrefix(schemaURL, "http") {
var err error
validator, err = schema.NewValidatorFromURL(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from '%s': %w", schemaURL, err)
}
} else {
var err error
validator, err = schema.NewValidatorFromFile(schemaURL)
if err != nil {
return fmt.Errorf("failed to instantiate validator from %s: %w", schemaURL, err)
}
sch, err := getSchema()
if err != nil {
return err
}
validator := schema.NewValidatorFromSchema(sch)
handleEvent := func(stdinEvent string) error {
evt := nostr.Event{}
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
@@ -56,7 +47,6 @@ nak event -k 1 -p not_a_pubkey | nak validate
}
stdout(evt)
return nil
}