Compare commits

..

6 Commits

6 changed files with 258 additions and 64 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)

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: ")

2
req.go
View File

@@ -107,7 +107,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 {