nak publish

This commit is contained in:
fiatjaf 2025-05-06 00:52:49 -03:00
parent 83195d9a00
commit 67e291e80d
8 changed files with 308 additions and 151 deletions

View File

@ -82,12 +82,6 @@ var count = &cli.Command{
biggerUrlSize = len(relay.URL)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
filter := nostr.Filter{}

252
event.go
View File

@ -140,10 +140,6 @@ example:
// try to connect to the relays here
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 {
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
@ -157,19 +153,11 @@ example:
os.Exit(3)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
doAuth := c.Bool("auth")
// then process input and generate events:
// will reuse this
@ -314,123 +302,7 @@ example:
}
stdout(result)
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
return nil
}
}
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.Hex(), "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 {
publish:
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
log("publishing to %s... ", color.CyanString(cleanUrl))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
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
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
if err := relay.Auth(ctx, kr.SignEvent); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
}
if len(successRelays) > 0 && c.Bool("nevent") {
log(nip19.EncodeNevent(evt.ID, successRelays, evt.PubKey) + "\n")
}
}
return nil
return publishFlow(ctx, c, kr, evt, relays)
}
for stdinEvent := range getJsonsOrBlank() {
@ -443,3 +315,125 @@ example:
return nil
},
}
func publishFlow(ctx context.Context, c *cli.Command, kr nostr.Signer, evt nostr.Event, relays []*nostr.Relay) error {
doAuth := c.Bool("auth")
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
os.Stdout.Sync()
if c.Bool("confirm") {
relaysStr := make([]string, len(relays))
for i, r := range relays {
relaysStr[i] = strings.ToLower(strings.Split(r.URL, "://")[1])
}
time.Sleep(time.Millisecond * 10)
if !askConfirmation("publish to [ " + strings.Join(relaysStr, " ") + " ]? ") {
return nil
}
}
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.Hex(), "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 {
publish:
cleanUrl, _ := strings.CutPrefix(relay.URL, "wss://")
log("publishing to %s... ", color.CyanString(cleanUrl))
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
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
pk, _ := kr.GetPublicKey(ctx)
npub := nip19.EncodeNpub(pk)
log("authenticating as %s... ", color.YellowString("%s…%s", npub[0:7], npub[58:]))
if err := relay.Auth(ctx, kr.SignEvent); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
}
if len(successRelays) > 0 && c.Bool("nevent") {
log(nip19.EncodeNevent(evt.ID, successRelays, evt.PubKey) + "\n")
}
}
return nil
}

View File

@ -27,13 +27,6 @@ var fetch = &cli.Command{
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
defer func() {
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
relay.Close()
return true
})
}()
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint nostr.PubKey

4
go.mod
View File

@ -4,7 +4,6 @@ go 1.24.1
require (
fiatjaf.com/lib v0.3.1
fiatjaf.com/nostr v0.0.1
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.4
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
@ -23,6 +22,7 @@ require (
)
require (
fiatjaf.com/nostr v0.0.0-20250506031545-0d99789a54e2 // indirect
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
@ -86,5 +86,3 @@ require (
google.golang.org/protobuf v1.36.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace fiatjaf.com/nostr => ../nostrlib

2
go.sum
View File

@ -1,6 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
fiatjaf.com/nostr v0.0.0-20250506031545-0d99789a54e2 h1:WDjFQ8hPUAvTDKderZ0NC6vaRBBxODPchKER4wuQdG8=
fiatjaf.com/nostr v0.0.0-20250506031545-0d99789a54e2/go.mod h1:VPs38Fc8J1XAErV750CXAmMUqIq3XEX9VZVj/LuQzzM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
github.com/FastFilter/xorfilter v0.2.1/go.mod h1:aumvdkhscz6YBZF9ZA/6O4fIoNod4YR50kIVGGZ7l9I=

View File

@ -44,6 +44,7 @@ var app = &cli.Command{
mcpServer,
curl,
fsCmd,
publish,
},
Version: version,
Flags: []cli.Flag{

181
publish.go Normal file
View File

@ -0,0 +1,181 @@
package main
import (
"context"
"fmt"
"io"
"os"
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/urfave/cli/v3"
)
var publish = &cli.Command{
Name: "publish",
Usage: "publishes a note with content from stdin",
Description: `reads content from stdin and publishes it as a note, optionally as a reply to another note.
example:
echo "hello world" | nak publish
echo "I agree!" | nak publish --reply nevent1...
echo "tagged post" | nak publish -t t=mytag -t e=someeventid`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
&cli.StringFlag{
Name: "reply",
Usage: "event id, naddr1 or nevent1 code to reply to",
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
Usage: "unix timestamp value for the created_at field",
DefaultText: "now",
Value: nostr.Now(),
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "confirm",
Usage: "ask before publishing the event",
Category: CATEGORY_EXTRAS,
},
),
Action: func(ctx context.Context, c *cli.Command) error {
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
evt := nostr.Event{
Kind: 1,
Content: strings.TrimSpace(string(content)),
Tags: make(nostr.Tags, 0, 4),
CreatedAt: nostr.Now(),
}
// handle timestamp flag
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
}
// handle reply flag
var replyRelays []string
if replyTo := c.String("reply"); replyTo != "" {
var replyEvent *nostr.Event
// try to decode as nevent or naddr first
if strings.HasPrefix(replyTo, "nevent1") || strings.HasPrefix(replyTo, "naddr1") {
_, value, err := nip19.Decode(replyTo)
if err != nil {
return fmt.Errorf("invalid reply target: %w", err)
}
switch pointer := value.(type) {
case nostr.EventPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
case nostr.EntityPointer:
replyEvent, _, err = sys.FetchSpecificEvent(ctx, pointer, sdk.FetchSpecificEventParameters{})
}
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
} else {
// try as raw event ID
id, err := nostr.IDFromHex(replyTo)
if err != nil {
return fmt.Errorf("invalid event id: %w", err)
}
replyEvent, _, err = sys.FetchSpecificEvent(ctx, nostr.EventPointer{ID: id}, sdk.FetchSpecificEventParameters{})
if err != nil {
return fmt.Errorf("failed to fetch reply target event: %w", err)
}
}
if replyEvent.Kind != 1 {
evt.Kind = 1111
}
// add reply tags
evt.Tags = append(evt.Tags,
nostr.Tag{"e", replyEvent.ID.Hex(), "", "reply"},
nostr.Tag{"p", replyEvent.PubKey.Hex()},
)
replyRelays = sys.FetchInboxRelays(ctx, replyEvent.PubKey, 3)
}
// handle other tags -- copied from event.go
tagFlags := c.StringSlice("tag")
for _, tagFlag := range tagFlags {
// tags are in the format key=value
tagName, tagValue, found := strings.Cut(tagFlag, "=")
tag := []string{tagName}
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
tag = append(tag, tagValues...)
}
evt.Tags = append(evt.Tags, tag)
}
// process the content
targetRelays := sys.PrepareNoteEvent(ctx, &evt)
// connect to all the relays (like event.go)
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return fmt.Errorf("failed to get our public key: %w", err)
}
relayUrls := sys.FetchWriteRelays(ctx, pk)
relayUrls = nostr.AppendUnique(relayUrls, targetRelays...)
relayUrls = nostr.AppendUnique(relayUrls, replyRelays...)
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},
)
if len(relays) == 0 {
if len(relayUrls) == 0 {
return fmt.Errorf("no relays to publish this note to.")
} else {
return fmt.Errorf("failed to connect to any of [ %v ].", relayUrls)
}
}
// sign the event
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing event: %w", err)
}
// print
stdout(evt.String())
// publish (like event.go)
return publishFlow(ctx, c, kr, evt, relays)
},
}

6
req.go
View File

@ -113,12 +113,6 @@ example:
for i, relay := range relays {
relayUrls[i] = relay.URL
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
// go line by line from stdin or run once with input from flags