mirror of
https://github.com/fiatjaf/nak.git
synced 2026-08-06 22:34:37 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a95407939 | ||
|
|
ac988a792d | ||
|
|
fa41c22c12 | ||
|
|
c2902bfb5d | ||
|
|
0f7268c071 | ||
|
|
f7a00cec10 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user