Compare commits

...

8 Commits

Author SHA1 Message Date
fiatjaf
95bed5d5a8 nak req --ids-only 2025-02-12 16:37:17 -03:00
fiatjaf
2e30dfe2eb wallet: fix nutzap error message. 2025-02-12 15:51:00 -03:00
fiatjaf
55c6f75b8a mcp: fix a bunch of stupid bugs. 2025-02-07 16:54:23 -03:00
fiatjaf
1f2492c9b1 fix multiline handler thing for when we're don't have any stdin. 2025-02-05 20:42:55 -03:00
fiatjaf
d00976a669 curl: assume POST when there is data and no method is specified. 2025-02-05 10:39:30 -03:00
fiatjaf
4392293ed6 curl method and negative make fixes. 2025-02-05 10:22:04 -03:00
fiatjaf
60d1292f80 parse multiline json from input on nak event and nak req, use iterators instead of channels for more efficient stdin parsing. 2025-02-05 09:44:16 -03:00
fiatjaf
6c634d8081 nak curl 2025-02-04 23:20:35 -03:00
9 changed files with 295 additions and 78 deletions

131
curl.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
var curlFlags []string
var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// cowboy parsing of curl flags to get the data we need for nip98
var url string
var method string
var presumedMethod string
curlBodyBuildingFlags := []string{
"-d",
"--data",
"--data-binary",
"--data-ascii",
"--data-raw",
"--data-urlencode",
"-F",
"--form",
"--form-string",
"--form-escape",
"--upload-file",
}
nextIsMethod := false
for _, f := range curlFlags {
if nextIsMethod {
method = f
method, _ = strings.CutPrefix(method, `"`)
method, _ = strings.CutSuffix(method, `"`)
method = strings.ToUpper(method)
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
url = f
} else if f == "--request" || f == "-X" {
nextIsMethod = true
continue
} else if slices.Contains(curlBodyBuildingFlags, f) ||
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
return strings.HasPrefix(f, s)
}) {
presumedMethod = "POST"
}
nextIsMethod = false
}
if url == "" {
return fmt.Errorf("can't create nip98 event: target url is empty")
}
if method == "" {
if presumedMethod != "" {
method = presumedMethod
} else {
method = "GET"
}
}
// make and sign event
evt := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", url},
{"method", method},
},
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return err
}
// the first 2 indexes of curlFlags were reserved for this
curlFlags[0] = "-H"
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
// call curl
cmd := exec.Command("curl", curlFlags...)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Run()
return nil
},
}
func realCurl() error {
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
keyFlags := make([]string, 0, 5)
for i := 0; i < len(os.Args[2:]); i++ {
arg := os.Args[i+2]
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
bareArg, _ := strings.CutPrefix(arg, "-")
bareArg, _ = strings.CutPrefix(bareArg, "-")
return slices.Contains(f.Names(), bareArg)
}) {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
}
} else {
curlFlags = append(curlFlags, arg)
}
}
return curl.Run(context.Background(), keyFlags)
}

View File

@@ -154,21 +154,20 @@ example:
doAuth := c.Bool("auth") doAuth := c.Bool("auth")
// then process input and generate events // then process input and generate events:
for stdinEvent := range getStdinLinesOrBlank() {
evt := nostr.Event{
Tags: make(nostr.Tags, 0, 3),
}
kindWasSupplied := false // will reuse this
var evt nostr.Event
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
mustRehashAndResign := false mustRehashAndResign := false
if stdinEvent != "" { if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil { return fmt.Errorf("invalid event received from stdin: %s", err)
ctx = lineProcessingError(ctx, "invalid event received from stdin: %s", err)
continue
}
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
} }
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") { if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
@@ -324,6 +323,14 @@ example:
log(nevent + "\n") log(nevent + "\n")
} }
} }
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, err.Error())
}
} }
exitIfLineProcessingError(ctx) exitIfLineProcessingError(ctx)

4
go.mod
View File

@@ -17,7 +17,8 @@ require (
github.com/mailru/easyjson v0.9.0 github.com/mailru/easyjson v0.9.0
github.com/mark3labs/mcp-go v0.8.3 github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3 github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.49.3 github.com/nbd-wtf/go-nostr v0.49.7
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8
) )
require ( require (
@@ -62,7 +63,6 @@ require (
github.com/wasilibs/go-re2 v1.3.0 // indirect github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect

4
go.sum
View File

@@ -128,8 +128,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.49.3 h1:7tsEdMZOtJ764JuMLffkbhVUi4yyf688dbqArLvItPs= github.com/nbd-wtf/go-nostr v0.49.7 h1:4D9XCqjTJYqUPMuNJI27W5gaiklnTI12IzzWIAOFepE=
github.com/nbd-wtf/go-nostr v0.49.3/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8= github.com/nbd-wtf/go-nostr v0.49.7/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

View File

@@ -4,11 +4,13 @@ import (
"bufio" "bufio"
"context" "context"
"fmt" "fmt"
"iter"
"math/rand" "math/rand"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url" "net/url"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
@@ -43,60 +45,79 @@ func isPiped() bool {
return stat.Mode()&os.ModeCharDevice == 0 return stat.Mode()&os.ModeCharDevice == 0
} }
func getStdinLinesOrBlank() chan string { func getJsonsOrBlank() iter.Seq[string] {
multi := make(chan string) var curr strings.Builder
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
single := make(chan string, 1) return func(yield func(string) bool) {
single <- "" hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
close(single) // we're look for an event, but it may be in multiple lines, so if json parsing fails
return single // we'll try the next line until we're successful
} else { curr.WriteString(stdinLine)
return multi stdinEvent := curr.String()
var dummy any
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
return true
}
if !yield(stdinEvent) {
return false
}
curr.Reset()
return true
})
if !hasStdin {
yield("{}")
}
} }
} }
func getStdinLinesOrArguments(args cli.Args) chan string { func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
return false
}
return true
})
if !hasStdin {
yield("")
}
}
}
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
return getStdinLinesOrArgumentsFromSlice(args.Slice()) return getStdinLinesOrArgumentsFromSlice(args.Slice())
} }
func getStdinLinesOrArgumentsFromSlice(args []string) chan string { func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
// try the first argument // try the first argument
if len(args) > 0 { if len(args) > 0 {
argsCh := make(chan string, 1) return slices.Values(args)
go func() {
for _, arg := range args {
argsCh <- arg
}
close(argsCh)
}()
return argsCh
} }
// try the stdin // try the stdin
multi := make(chan string) return func(yield func(string) bool) {
if !writeStdinLinesOrNothing(multi) { writeStdinLinesOrNothing(yield)
close(multi)
} }
return multi
} }
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) { func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
if isPiped() { if isPiped() {
// piped // piped
go func() { scanner := bufio.NewScanner(os.Stdin)
scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024) hasEmittedAtLeastOne := false
hasEmittedAtLeastOne := false for scanner.Scan() {
for scanner.Scan() { if !yield(strings.TrimSpace(scanner.Text())) {
ch <- strings.TrimSpace(scanner.Text()) return
hasEmittedAtLeastOne = true
} }
if !hasEmittedAtLeastOne { hasEmittedAtLeastOne = true
ch <- "" }
} return hasEmittedAtLeastOne
close(ch)
}()
return true
} else { } else {
// not piped // not piped
return false return false

11
main.go
View File

@@ -39,6 +39,7 @@ var app = &cli.Command{
outbox, outbox,
wallet, wallet,
mcpServer, mcpServer,
curl,
}, },
Version: version, Version: version,
Flags: []cli.Flag{ Flags: []cli.Flag{
@@ -140,6 +141,16 @@ func main() {
Usage: "prints the version", Usage: "prints the version",
} }
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
stdout(err)
colors.reset()
os.Exit(1)
}
return
}
if err := app.Run(context.Background(), os.Args); err != nil { if err := app.Run(context.Background(), os.Args); err != nil {
stdout(err) stdout(err)
colors.reset() colors.reset()

52
mcp.go
View File

@@ -27,6 +27,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("publish_note", s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"), mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("relay",
mcp.Description("Relay to publish the note to"),
),
mcp.WithString("content", mcp.WithString("content",
mcp.Required(), mcp.Required(),
mcp.Description("Arbitrary string to be published"), mcp.Description("Arbitrary string to be published"),
@@ -38,6 +41,11 @@ var mcpServer = &cli.Command{
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content, _ := request.Params.Arguments["content"].(string) content, _ := request.Params.Arguments["content"].(string)
mention, _ := request.Params.Arguments["mention"].(string) mention, _ := request.Params.Arguments["mention"].(string)
relayI, ok := request.Params.Arguments["relay"]
var relay string
if ok {
relay, _ = relayI.(string)
}
if mention != "" && !nostr.IsValidPublicKey(mention) { if mention != "" && !nostr.IsValidPublicKey(mention) {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -71,16 +79,33 @@ var mcpServer = &cli.Command{
relays = []string{"nos.lol", "relay.damus.io"} relays = []string{"nos.lol", "relay.damus.io"}
} }
for res := range sys.Pool.PublishMany(ctx, []string{"nos.lol"}, evt) { // extra relay specified
relays = append(relays, relay)
result := strings.Builder{}
result.WriteString(
fmt.Sprintf("the event we generated has id '%s', kind '%d' and is signed by pubkey '%s'. ",
evt.ID,
evt.Kind,
evt.PubKey,
),
)
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
if res.Error != nil { if res.Error != nil {
return mcp.NewToolResultError( result.WriteString(
fmt.Sprintf("there was an error publishing the event to the relay %s", fmt.Sprintf("there was an error publishing the event to the relay %s. ",
res.RelayURL), res.RelayURL),
), nil )
} else {
result.WriteString(
fmt.Sprintf("the event was successfully published to the relay %s. ",
res.RelayURL),
)
} }
} }
return mcp.NewToolResultText("event was successfully published with id " + evt.ID), nil return mcp.NewToolResultText(result.String()), nil
}) })
s.AddTool(mcp.NewTool("resolve_nostr_uri", s.AddTool(mcp.NewTool("resolve_nostr_uri",
@@ -152,13 +177,9 @@ var mcpServer = &cli.Command{
mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Description("Public key of Nostr user we want to know the relay from where to read"),
), ),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string) pubkey, _ := request.Params.Arguments["pubkey"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}}) res := sys.FetchOutboxRelays(ctx, pubkey, 1)
if re == nil { return mcp.NewToolResultText(res[0]), nil
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
}
return mcp.NewToolResultText(re.PubKey), nil
}) })
s.AddTool(mcp.NewTool("read_events_from_relay", s.AddTool(mcp.NewTool("read_events_from_relay",
@@ -182,7 +203,11 @@ var mcpServer = &cli.Command{
relay, _ := request.Params.Arguments["relay"].(string) relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int) limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int) kind, _ := request.Params.Arguments["kind"].(int)
pubkey, _ := request.Params.Arguments["pubkey"].(string) pubkeyI, ok := request.Params.Arguments["pubkey"]
var pubkey string
if ok {
pubkey, _ = pubkeyI.(string)
}
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) { if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -198,7 +223,6 @@ var mcpServer = &cli.Command{
events := sys.Pool.SubManyEose(ctx, []string{relay}, nostr.Filters{filter}) events := sys.Pool.SubManyEose(ctx, []string{relay}, nostr.Filters{filter})
result := strings.Builder{} result := strings.Builder{}
for ie := range events { for ie := range events {
result.WriteString("author public key: ") result.WriteString("author public key: ")

41
req.go
View File

@@ -9,6 +9,7 @@ import (
"github.com/fiatjaf/cli/v3" "github.com/fiatjaf/cli/v3"
"github.com/mailru/easyjson" "github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip77"
) )
const ( const (
@@ -32,6 +33,10 @@ example:
DisableSliceFlagSeparator: true, DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags, Flags: append(defaultKeyFlags,
append(reqFilterFlags, append(reqFilterFlags,
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "stream", Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned", Usage: "keep the subscription open, printing all events as they are returned",
@@ -107,7 +112,7 @@ example:
}() }()
} }
for stdinFilter := range getStdinLinesOrBlank() { for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{} filter := nostr.Filter{}
if stdinFilter != "" { if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil { if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
@@ -121,15 +126,33 @@ example:
} }
if len(relayUrls) > 0 { if len(relayUrls) > 0 {
fn := sys.Pool.SubManyEose if c.Bool("ids-only") {
if c.Bool("paginate") { seen := make(map[string]struct{}, max(500, filter.Limit))
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit")) for _, url := range relayUrls {
} else if c.Bool("stream") { ch, err := nip77.FetchIDsOnly(ctx, url, filter)
fn = sys.Pool.SubMany if err != nil {
} log("negentropy call to %s failed: %s", url, err)
continue
}
for id := range ch {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id)
}
}
} else {
fn := sys.Pool.SubManyEose
if c.Bool("paginate") {
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
} else if c.Bool("stream") {
fn = sys.Pool.SubMany
}
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) { for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event) stdout(ie.Event)
}
} }
} else { } else {
// no relays given, will just print the filter // no relays given, will just print the filter

View File

@@ -316,8 +316,8 @@ var wallet = &cli.Command{
}, },
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice() args := c.Args().Slice()
if len(args) >= 2 { if len(args) < 2 {
return fmt.Errorf("must be called as `nak wallet send <amount> <target>...") return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
} }
w, closew, err := prepareWallet(ctx, c) w, closew, err := prepareWallet(ctx, c)