package main import ( "bufio" "context" "errors" "fmt" "iter" "math/rand" "net/url" "os" "os/exec" "path/filepath" "runtime" "slices" "strconv" "strings" "sync" "time" "unicode" "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip05" "fiatjaf.com/nostr/nip19" "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" "golang.org/x/term" ) var sys *sdk.System var json = jsoniter.ConfigFastest const ( LINE_PROCESSING_ERROR = iota ) var ( log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) } logverbose = func(msg string, args ...any) {} // by default do nothing stdout = func(args ...any) { fmt.Fprintln(color.Output, args...) } ) var connectTimeout = 2 * time.Second func isPiped() bool { stat, err := os.Stdin.Stat() if err != nil { panic(err) } mode := stat.Mode() is := mode&os.ModeCharDevice == 0 return is } func getJsonsOrBlank() iter.Seq[string] { var curr strings.Builder var finalJsonErr error return func(yield func(string) bool) { stopped := false hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool { // we're look for an event, but it may be in multiple lines, so if json parsing fails // we'll try the next line until we're successful curr.WriteString(stdinLine) stdinEvent := curr.String() var dummy any if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil { finalJsonErr = err return true } finalJsonErr = nil if !yield(stdinEvent) { stopped = true return false } curr.Reset() return true }) if stopped { return } if !hasStdin { if !yield("{}") { return } } if finalJsonErr != nil { log(color.YellowString("stdin json parse error: %s", finalJsonErr)) } } } func getStdinLinesOrBlank() iter.Seq[string] { return func(yield func(string) bool) { stopped := false hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool { if !yield(stdinLine) { stopped = true return false } return true }) if stopped { return } if !hasStdin { if !yield("") { return } } } } func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] { return getStdinLinesOrArgumentsFromSlice(args.Slice()) } func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] { // try the first argument if len(args) > 0 { return slices.Values(args) } // try the stdin return func(yield func(string) bool) { writeStdinLinesOrNothing(yield) } } func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) { if isPiped() { // piped scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024) hasEmittedAtLeastOne := false for scanner.Scan() { if !yield(strings.TrimSpace(scanner.Text())) { return } hasEmittedAtLeastOne = true } return hasEmittedAtLeastOne } else { // not piped return false } } func normalizeAndValidateRelayURLs(wsurls []string) error { for i, wsurl := range wsurls { wsurl = nostr.NormalizeURL(wsurl) wsurls[i] = wsurl u, err := url.Parse(wsurl) if err != nil { return fmt.Errorf("invalid relay url '%s': %s", wsurl, err) } if u.Scheme != "ws" && u.Scheme != "wss" { return fmt.Errorf("relay url must use wss:// or ws:// schemes, got '%s'", wsurl) } if u.Host == "" { return fmt.Errorf("relay url '%s' is missing the hostname", wsurl) } } return nil } func connectToAllRelays( ctx context.Context, c *cli.Command, relayUrls []string, ) []*nostr.Relay { // first pass to check if these are valid relay URLs for i, url := range relayUrls { url = nostr.NormalizeURL(url) relayUrls[i] = url if !nostr.IsValidRelayURL(url) { log("invalid relay URL: %s\n", url) os.Exit(4) } } relays := make([]*nostr.Relay, 0, len(relayUrls)) if supportsDynamicMultilineMagic() { // overcomplicated multiline rendering magic lines := make([][][]byte, len(relayUrls)) 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() wg := sync.WaitGroup{} wg.Add(len(relayUrls)) mu := sync.Mutex{} // guards lines and relays, written from the goroutines below for i, url := range relayUrls { lines[i] = make([][]byte, 1, 2) logthis := func(s string, args ...any) { mu.Lock() lines[i] = append(lines[i], []byte(fmt.Sprintf(s, args...))) render() mu.Unlock() } colorizepreamble := func(c func(string, ...any) string) { mu.Lock() lines[i][0] = []byte(fmt.Sprintf("%s... ", c(url))) mu.Unlock() } colorizepreamble(color.CyanString) go func() { relay := connectToSingleRelay(ctx, c, url, colorizepreamble, logthis) if relay != nil { mu.Lock() relays = append(relays, relay) mu.Unlock() } wg.Done() }() } wg.Wait() } else { // simple flow for _, url := range relayUrls { log("connecting to %s... ", color.CyanString(strings.Split(url, "/")[2])) relay := connectToSingleRelay(ctx, c, url, nil, log) if relay != nil { relays = append(relays, relay) } log("\n") } } return relays } func connectToSingleRelay( ctx context.Context, c *cli.Command, url string, colorizepreamble func(c func(string, ...any) string), logthis func(s string, args ...any), ) *nostr.Relay { nm := nostr.NormalizeURL(url) relay, ok := sys.Pool.Relays.Load(nm) if !ok || relay == nil || !relay.IsConnected() { connectCtx, cancel := context.WithTimeout(context.Background(), connectTimeout) defer cancel() var err error if relay, err = nostr.RelayConnect(connectCtx, url, sys.Pool.RelayOptions); err != nil { if colorizepreamble != nil { colorizepreamble(colors.errorf) } logthis(clampError(err, len(url)+12)) return nil } sys.Pool.Relays.Store(nm, relay) go func(r *nostr.Relay, relayURL string) { <-r.Context().Done() if current, ok := sys.Pool.Relays.Load(relayURL); ok && current == r { sys.Pool.Relays.Delete(relayURL) } }(relay, nm) } if c.Bool("force-pre-auth") { if colorizepreamble != nil { colorizepreamble(color.YellowString) } logthis("waiting for auth challenge... ") time.Sleep(time.Millisecond * 200) for range 5 { if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error { challengeTag := authEvent.Tags.Find("challenge") if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" { return fmt.Errorf("auth not received yet *****") // what a giant hack } return authSigner(ctx, c, logthis, authEvent) }); err == nil { // auth succeeded goto preauthSuccess } else { // auth failed if strings.HasSuffix(err.Error(), "auth not received yet *****") { time.Sleep(time.Second) continue } else { if colorizepreamble != nil { colorizepreamble(colors.errorf) } logthis(err.Error()) return nil } } } if colorizepreamble != nil { colorizepreamble(colors.errorf) } logthis("failed to get an AUTH challenge in enough time.") return nil } preauthSuccess: if colorizepreamble != nil { colorizepreamble(colors.successf) } logthis("ok.") return relay } func clearLines(lineCount int) { for i := 0; i < lineCount; i++ { os.Stderr.Write([]byte("\033[0A\033[2K\r")) } } func supportsDynamicMultilineMagic() bool { if runtime.GOOS == "windows" { return false } if !term.IsTerminal(0) { return false } if !isatty.IsTerminal(os.Stdout.Fd()) { return false } width, _, err := term.GetSize(int(os.Stderr.Fd())) if err != nil { return false } if width < 110 { return false } return true } func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context { log(msg+"\n", args...) return context.WithValue(ctx, LINE_PROCESSING_ERROR, true) } func exitIfLineProcessingError(ctx context.Context) { if val := ctx.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) { os.Exit(123) } } const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" func randString(n int) string { b := make([]byte, n) for i := range b { b[i] = letterBytes[rand.Intn(len(letterBytes))] } return string(b) } func unwrapAll(err error) error { low := err for n := low; n != nil; n = errors.Unwrap(low) { low = n } return low } func clampMessage(msg string, prefixAlreadyPrinted int) string { termSize, _, _ := term.GetSize(int(os.Stderr.Fd())) prf := "expected handshake response status code 101 but got " if len(msg) > len(prf) && msg[0:len(prf)] == prf { msg = "status " + msg[len(prf):] } if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize { msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…" } return msg } func clampError(err error, prefixAlreadyPrinted int) string { termSize, _, _ := term.GetSize(0) msg := err.Error() if len(msg) > termSize-prefixAlreadyPrinted { err = unwrapAll(err) msg = clampMessage(err.Error(), prefixAlreadyPrinted) } return msg } func askConfirmation(msg string) bool { if isPiped() { tty, err := tty.Open() if err != nil { return false } defer tty.Close() log(color.YellowString(msg)) answer, err := tty.ReadString() if err != nil { return false } // print newline after password input fmt.Fprintln(os.Stderr) answer = strings.TrimSpace(string(answer)) return answer == "y" || answer == "yes" } else { config := &readline.Config{ Stdout: color.Error, Prompt: color.YellowString(msg), InterruptPrompt: "^C", DisableAutoSaveHistory: true, EnableMask: false, MaskRune: '*', } rl, err := readline.NewEx(config) if err != nil { return false } answer, err := rl.Readline() if err != nil { return false } answer = strings.ToLower(strings.TrimSpace(answer)) return answer == "y" || answer == "yes" } } func parsePubKey(value string) (nostr.PubKey, error) { if nip05.IsValidIdentifier(value) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) pp, err := nip05.QueryIdentifier(ctx, value) cancel() if err != nil { return nostr.ZeroPK, err } return pp.PublicKey, nil } pk, err := nostr.PubKeyFromHex(value) if err == nil { return pk, nil } if prefix, decoded, err := nip19.Decode(value); err == nil { switch prefix { case "npub": if pk, ok := decoded.(nostr.PubKey); ok { return pk, nil } case "nprofile": if profile, ok := decoded.(nostr.ProfilePointer); ok { return profile.PublicKey, nil } } } return nostr.PubKey{}, fmt.Errorf("invalid pubkey (\"%s\"): expected hex, npub, or nprofile", value) } func parseSecretKey(input string) (nostr.SecretKey, error) { if prefix, ski, err := nip19.Decode(input); err == nil && prefix == "nsec" { return ski.(nostr.SecretKey), nil } sk, err := nostr.SecretKeyFromHex(input) if err != nil { return nostr.SecretKey{}, fmt.Errorf("invalid secret key: %w", err) } return sk, nil } func parseEventID(value string) (nostr.ID, error) { id, err := nostr.IDFromHex(value) if err == nil { return id, nil } if prefix, decoded, err := nip19.Decode(value); err == nil { switch prefix { case "note": if event, ok := decoded.(nostr.EventPointer); ok { return event.ID, nil } case "nevent": if event, ok := decoded.(nostr.EventPointer); ok { return event.ID, nil } } } return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value) } func decodeTagValue(value string, letter rune) string { letter = unicode.ToLower(letter) if letter == 'p' { if nip05.IsValidIdentifier(value) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) pp, err := nip05.QueryIdentifier(ctx, value) cancel() if err == nil { return pp.PublicKey.Hex() } } } if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) || ((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) || ((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) { if ptr, err := nip19.ToPointer(value); err == nil { return ptr.AsTagReference() } } return value } func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) { fullpath := filepath.Join(os.TempDir(), filename) if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil { return "", fmt.Errorf("failed to create temp directory: %w", err) } if wipe { if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("failed to remove temp file: %w", err) } } tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } if _, err := tmp.WriteString(initialContent); err != nil { tmp.Close() return "", fmt.Errorf("failed to write temp file: %w", err) } if err := tmp.Close(); err != nil { return "", fmt.Errorf("failed to close temp file: %w", err) } editor := strings.TrimSpace(os.Getenv("VISUAL")) if editor == "" { editor = strings.TrimSpace(os.Getenv("EDITOR")) } if editor == "" { editor = "edit" } parts := strings.Fields(editor) if len(parts) == 0 { return "", fmt.Errorf("failed to parse editor command '%s'", editor) } args := append(parts[1:], tmp.Name()) cmd := exec.Command(parts[0], args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("editor command failed: %w", err) } data, err := os.ReadFile(tmp.Name()) if err != nil { return "", fmt.Errorf("failed to read edited temp file: %w", err) } return string(data), nil } func clampWithEllipsis(s string, size int) string { if len(s) <= size { return s } 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 { if n > 65535 { return 0, fmt.Errorf("kind number %d is out of range (0-65535)", n) } 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 := "" 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 italicf func(string, ...any) string bold func(...any) string boldf func(string, ...any) string underline func(...any) string underlinef func(string, ...any) string error func(...any) string errorf func(string, ...any) string success func(...any) string successf func(string, ...any) string }{ color.New(color.Reset).Print, color.New(color.Italic).Sprint, color.New(color.Italic).Sprintf, color.New(color.Bold).Sprint, color.New(color.Bold).Sprintf, color.New(color.Underline).Sprint, color.New(color.Underline).Sprintf, color.New(color.Bold, color.FgHiRed).Sprint, color.New(color.Bold, color.FgHiRed).Sprintf, color.New(color.Bold, color.FgHiGreen).Sprint, color.New(color.Bold, color.FgHiGreen).Sprintf, } func combineFlags(flagSlices [][]cli.Flag, extraFlags ...cli.Flag) []cli.Flag { total := 0 for _, s := range flagSlices { total += len(s) } total += len(extraFlags) result := make([]cli.Flag, 0, total) for _, s := range flagSlices { result = append(result, s...) } result = append(result, extraFlags...) return result }