diff --git a/count.go b/count.go index 905d0f0..bad4b8e 100644 --- a/count.go +++ b/count.go @@ -82,12 +82,6 @@ var count = &cli.Command{ biggerUrlSize = len(relay.URL) } } - - defer func() { - for _, relay := range relays { - relay.Close() - } - }() } filter := nostr.Filter{} diff --git a/event.go b/event.go index be14ce7..d385e76 100644 --- a/event.go +++ b/event.go @@ -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 +} diff --git a/fetch.go b/fetch.go index 0bcef74..7a1c370 100644 --- a/fetch.go +++ b/fetch.go @@ -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 diff --git a/go.mod b/go.mod index 1d53d7e..140d5d3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4206862..3c5965f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index f0ddfb4..136c73c 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ var app = &cli.Command{ mcpServer, curl, fsCmd, + publish, }, Version: version, Flags: []cli.Flag{ diff --git a/publish.go b/publish.go new file mode 100644 index 0000000..e297030 --- /dev/null +++ b/publish.go @@ -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= 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) + }, +} diff --git a/req.go b/req.go index c55c1fa..c1fec94 100644 --- a/req.go +++ b/req.go @@ -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