├── pkg ├── nostr │ ├── .gitignore │ ├── log_normal.go │ ├── timestamp.go │ ├── log.go │ ├── nip19 │ │ ├── utils.go │ │ ├── nip19.go │ │ └── nip19_test.go │ ├── pointers.go │ ├── normalize.go │ ├── normalize_test.go │ ├── log_debug.go │ ├── metadata.go │ ├── subscription_test.go │ ├── keys.go │ ├── LICENSE.md │ ├── nip06 │ │ └── nip06.go │ ├── nip04 │ │ ├── nip04_test.go │ │ └── nip04.go │ ├── nip10 │ │ └── nip10.go │ ├── nip11 │ │ ├── fetch.go │ │ └── types.go │ ├── tag_test.go │ ├── nip26 │ │ └── nip26_test.go │ ├── event_extra.go │ ├── sdk │ │ ├── input.go │ │ ├── references.go │ │ └── references_test.go │ ├── utils.go │ ├── nip42 │ │ └── nip42.go │ ├── nip05 │ │ └── nip05.go │ ├── nip13 │ │ ├── nip13.go │ │ └── nip13_test.go │ ├── filter.go │ ├── subscription.go │ ├── pool.go │ ├── filter_test.go │ ├── tags.go │ ├── README.md │ ├── event.go │ ├── envelopes_test.go │ ├── event_easyjson.go │ ├── connection.go │ ├── event_test.go │ ├── envelopes.go │ ├── filter_easyjson.go │ └── relay_test.go ├── ishell │ ├── .gitignore │ ├── .travis.yml │ ├── utils_windows.go │ ├── utils_unix.go │ ├── functions.go │ ├── LICENSE │ ├── CHANGES.md │ ├── completer.go │ ├── context.go │ ├── reader.go │ ├── command.go │ ├── example │ │ └── main.go │ ├── actions.go │ ├── README.md │ └── progress.go └── unostr │ └── unostr.go ├── cmd ├── control │ ├── internal │ │ ├── storage │ │ │ ├── default.json │ │ │ └── storage.go │ │ └── control │ │ │ └── control.go │ └── main.go └── agent │ ├── internal │ ├── storage │ │ ├── empty_gen.py │ │ └── storage.go │ └── agent │ │ ├── handler.go │ │ └── agent.go │ └── main.go ├── .gitignore ├── model ├── unostr.go ├── event.go └── storage.go ├── LICENSE ├── go.mod ├── README.md └── utils └── utils.go /pkg/nostr/.gitignore: -------------------------------------------------------------------------------- 1 | go-nostr 2 | -------------------------------------------------------------------------------- /pkg/ishell/.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | example/example 4 | .idea/ 5 | *.iml 6 | -------------------------------------------------------------------------------- /pkg/nostr/log_normal.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package nostr 4 | 5 | func debugLog(str string, args ...any) { 6 | } 7 | -------------------------------------------------------------------------------- /pkg/ishell/.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | script: 3 | - go vet 4 | - test -z $(gofmt -l .) 5 | - go test -v ./... 6 | - go build 7 | go: 8 | - 1.10.x 9 | -------------------------------------------------------------------------------- /pkg/ishell/utils_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package ishell 4 | 5 | import ( 6 | "github.com/abiosoft/readline" 7 | ) 8 | 9 | func clearScreen(s *Shell) error { 10 | return readline.ClearScreen(s.writer) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/control/internal/storage/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "relay": "ws://127.0.0.1:7447", 3 | "proxy": "", 4 | "connect_timeout": "5s", 5 | "ping_interval": "10s", 6 | "private_key": "", 7 | "agent_public_key_list": null 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | *.test 8 | *.out 9 | 10 | .settings/ 11 | .vscode/ 12 | log/ 13 | bin/ 14 | test/ 15 | */.DS_Store 16 | main 17 | __debug_bin 18 | 19 | history.txt 20 | .control.json 21 | 22 | /agent* -------------------------------------------------------------------------------- /pkg/nostr/timestamp.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import "time" 4 | 5 | type Timestamp int64 6 | 7 | func Now() Timestamp { 8 | return Timestamp(time.Now().Unix()) 9 | } 10 | 11 | func (t Timestamp) Time() time.Time { 12 | return time.Unix(int64(t), 0) 13 | } 14 | -------------------------------------------------------------------------------- /model/unostr.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "nrat/pkg/nostr" 7 | ) 8 | 9 | type Unostr interface { 10 | Connect() error 11 | SetConnectEvent(f func()) 12 | Relay() *nostr.Relay 13 | Close() error 14 | ConnectTimeout() time.Duration 15 | } 16 | -------------------------------------------------------------------------------- /pkg/ishell/utils_unix.go: -------------------------------------------------------------------------------- 1 | // +build darwin dragonfly freebsd linux,!appengine netbsd openbsd solaris 2 | 3 | package ishell 4 | 5 | import ( 6 | "github.com/abiosoft/readline" 7 | ) 8 | 9 | func clearScreen(s *Shell) error { 10 | _, err := readline.ClearScreen(s.writer) 11 | return err 12 | } 13 | -------------------------------------------------------------------------------- /pkg/nostr/log.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | var ( 9 | // call SetOutput on InfoLogger to enable info logging 10 | InfoLogger = log.New(os.Stderr, "[go-nostr][info] ", log.LstdFlags) 11 | 12 | // call SetOutput on DebugLogger to enable debug logging 13 | DebugLogger = log.New(os.Stderr, "[go-nostr][debug] ", log.LstdFlags) 14 | ) 15 | -------------------------------------------------------------------------------- /cmd/control/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "uw/uboot" 5 | 6 | "nrat/cmd/control/internal/control" 7 | "nrat/cmd/control/internal/storage" 8 | "nrat/pkg/unostr" 9 | ) 10 | 11 | func main() { 12 | uboot.NewBoot().Register( 13 | uboot.Uint("storage", uboot.UintNormal, storage.StorageUint), 14 | uboot.Uint("unostr", uboot.UintNormal, unostr.UnostrUint), 15 | uboot.Uint("control", uboot.UintAfter, control.ControlUint), 16 | ).BootTimeout(0).Start() 17 | } 18 | -------------------------------------------------------------------------------- /cmd/agent/internal/storage/empty_gen.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | 5 | parser = argparse.ArgumentParser() 6 | parser.add_argument("size", help="empty file size", type=int) 7 | args = parser.parse_args() 8 | start = "%%%#" 9 | end = start[::-1] 10 | 11 | print(f"generating empty file of size {args.size} bytes") 12 | print(f"start: {start}, end: {end}") 13 | 14 | with open(f"empty.bin", "w") as f: 15 | f.write(start) 16 | f.write("\0" * (args.size * 1024)) 17 | f.write(end) 18 | -------------------------------------------------------------------------------- /pkg/nostr/nip19/utils.go: -------------------------------------------------------------------------------- 1 | package nip19 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | const ( 8 | TLVDefault uint8 = 0 9 | TLVRelay uint8 = 1 10 | TLVAuthor uint8 = 2 11 | TLVKind uint8 = 3 12 | ) 13 | 14 | func readTLVEntry(data []byte) (typ uint8, value []byte) { 15 | if len(data) < 2 { 16 | return 0, nil 17 | } 18 | 19 | typ = data[0] 20 | length := int(data[1]) 21 | value = data[2 : 2+length] 22 | return 23 | } 24 | 25 | func writeTLVEntry(buf *bytes.Buffer, typ uint8, value []byte) { 26 | length := len(value) 27 | buf.WriteByte(typ) 28 | buf.WriteByte(uint8(length)) 29 | buf.Write(value) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/nostr/pointers.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | type ProfilePointer struct { 4 | PublicKey string `json:"pubkey"` 5 | Relays []string `json:"relays,omitempty"` 6 | } 7 | 8 | type EventPointer struct { 9 | ID string `json:"id"` 10 | Relays []string `json:"relays,omitempty"` 11 | Author string `json:"author,omitempty"` 12 | Kind int `json:"kind,omitempty"` 13 | } 14 | 15 | type EntityPointer struct { 16 | PublicKey string `json:"pubkey"` 17 | Kind int `json:"kind,omitempty"` 18 | Identifier string `json:"identifier,omitempty"` 19 | Relays []string `json:"relays,omitempty"` 20 | } 21 | -------------------------------------------------------------------------------- /model/event.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | EventSeparator = "\x1e" 10 | DataSeparator = "\x1f" 11 | ) 12 | 13 | type Event struct { 14 | Id string // 编号 15 | Type string // 事件类型 16 | Error string // 错误消息 17 | Content string // 事件内容 18 | } 19 | 20 | func (evt *Event) Encode() string { 21 | return evt.Type + EventSeparator + evt.Error + EventSeparator + evt.Content 22 | } 23 | 24 | func (evt *Event) Decode(t string) error { 25 | n := strings.SplitN(t, EventSeparator, 3) 26 | if len(n) < 3 { 27 | return fmt.Errorf("invalid event: %s", t) 28 | } 29 | 30 | evt.Type, evt.Error, evt.Content = n[0], n[1], n[2] 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /cmd/agent/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "uw/uboot" 5 | "uw/ulog" 6 | 7 | "nrat/cmd/agent/internal/agent" 8 | "nrat/cmd/agent/internal/storage" 9 | "nrat/pkg/unostr" 10 | 11 | "github.com/abiosoft/readline" 12 | ) 13 | 14 | func main() { 15 | ulog.GlobalFormat().SetWriter(func(s string) { 16 | _, _ = readline.Stdout.Write([]byte(s)) 17 | }) 18 | 19 | uboot.NewBoot().Register( 20 | uboot.Uint("storage", uboot.UintNormal, storage.StorageUint), 21 | uboot.Uint("unostr", uboot.UintNormal, unostr.UnostrUint), 22 | uboot.Uint("agent", uboot.UintNormal, agent.AgentUint), 23 | uboot.Uint("loop", uboot.UintNormal, func(c *uboot.Context) error { 24 | select {} 25 | }), 26 | ).BootTimeout(0).Start() 27 | } 28 | -------------------------------------------------------------------------------- /pkg/nostr/normalize.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | // NormalizeURL normalizes the url and replaces http://, https:// schemes by ws://, wss://. 9 | func NormalizeURL(u string) string { 10 | if u == "" { 11 | return "" 12 | } 13 | 14 | u = strings.TrimSpace(u) 15 | u = strings.ToLower(u) 16 | 17 | if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "ws") { 18 | u = "wss://" + u 19 | } 20 | p, err := url.Parse(u) 21 | if err != nil { 22 | return "" 23 | } 24 | 25 | if p.Scheme == "http" { 26 | p.Scheme = "ws" 27 | } else if p.Scheme == "https" { 28 | p.Scheme = "wss" 29 | } 30 | 31 | p.Path = strings.TrimRight(p.Path, "/") 32 | 33 | return p.String() 34 | } 35 | -------------------------------------------------------------------------------- /pkg/ishell/functions.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func exitFunc(c *Context) { 8 | c.Stop() 9 | } 10 | 11 | func helpFunc(c *Context) { 12 | c.Println(c.HelpText()) 13 | } 14 | 15 | func clearFunc(c *Context) { 16 | err := c.ClearScreen() 17 | if err != nil { 18 | c.Err(err) 19 | } 20 | } 21 | 22 | func addDefaultFuncs(s *Shell) { 23 | s.AddCmd(&Cmd{ 24 | Name: "exit", 25 | Help: "exit the program", 26 | Func: exitFunc, 27 | }) 28 | s.AddCmd(&Cmd{ 29 | Name: "help", 30 | Help: "display help", 31 | Func: helpFunc, 32 | }) 33 | s.AddCmd(&Cmd{ 34 | Name: "clear", 35 | Help: "clear the screen", 36 | Func: clearFunc, 37 | }) 38 | s.Interrupt(interruptFunc) 39 | } 40 | 41 | func interruptFunc(c *Context, count int, line string) { 42 | if count >= 2 { 43 | c.Println("Interrupted") 44 | os.Exit(1) 45 | } 46 | c.Println("Input Ctrl-c once more to exit") 47 | } 48 | -------------------------------------------------------------------------------- /pkg/nostr/normalize_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import "fmt" 4 | 5 | func ExampleNormalizeURL() { 6 | fmt.Println(NormalizeURL("")) 7 | fmt.Println(NormalizeURL("wss://x.com/y")) 8 | fmt.Println(NormalizeURL("wss://x.com/y/")) 9 | fmt.Println(NormalizeURL("http://x.com/y")) 10 | fmt.Println(NormalizeURL(NormalizeURL("http://x.com/y"))) 11 | fmt.Println(NormalizeURL("wss://x.com")) 12 | fmt.Println(NormalizeURL("wss://x.com/")) 13 | fmt.Println(NormalizeURL(NormalizeURL(NormalizeURL("wss://x.com/")))) 14 | fmt.Println(NormalizeURL("x.com")) 15 | fmt.Println(NormalizeURL("x.com/")) 16 | fmt.Println(NormalizeURL("x.com////")) 17 | fmt.Println(NormalizeURL("x.com/?x=23")) 18 | 19 | // Output: 20 | // 21 | // wss://x.com/y 22 | // wss://x.com/y 23 | // ws://x.com/y 24 | // ws://x.com/y 25 | // wss://x.com 26 | // wss://x.com 27 | // wss://x.com 28 | // wss://x.com 29 | // wss://x.com 30 | // wss://x.com 31 | // wss://x.com?x=23 32 | } 33 | -------------------------------------------------------------------------------- /pkg/nostr/log_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package nostr 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | func debugLog(str string, args ...any) { 11 | // this is such that we don't modify the actual args that may be used outside of this function 12 | printableArgs := make([]any, len(args)) 13 | 14 | for i, v := range args { 15 | printableArgs[i] = stringify(v) 16 | } 17 | 18 | DebugLogger.Printf(str, printableArgs...) 19 | } 20 | 21 | func stringify(anything any) any { 22 | switch v := anything.(type) { 23 | case []any: 24 | // this is such that we don't modify the actual values that may be used outside of this function 25 | printableValues := make([]any, len(v)) 26 | for i, subv := range v { 27 | printableValues[i] = stringify(subv) 28 | } 29 | return printableValues 30 | case []json.RawMessage: 31 | j, _ := json.Marshal(v) 32 | return string(j) 33 | case []byte: 34 | return string(v) 35 | case fmt.Stringer: 36 | return v.String() 37 | default: 38 | return v 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/nostr/metadata.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type ProfileMetadata struct { 9 | Name string `json:"name,omitempty"` 10 | DisplayName string `json:"display_name,omitempty"` 11 | About string `json:"about,omitempty"` 12 | Website string `json:"website,omitempty"` 13 | Picture string `json:"picture,omitempty"` 14 | Banner string `json:"banner,omitempty"` 15 | NIP05 string `json:"nip05,omitempty"` 16 | LUD16 string `json:"lud16,omitempty"` 17 | } 18 | 19 | func ParseMetadata(event Event) (*ProfileMetadata, error) { 20 | if event.Kind != 0 { 21 | return nil, fmt.Errorf("event %s is kind %d, not 0", event.ID, event.Kind) 22 | } 23 | 24 | var meta ProfileMetadata 25 | err := json.Unmarshal([]byte(event.Content), &meta) 26 | if err != nil { 27 | cont := event.Content 28 | if len(cont) > 100 { 29 | cont = cont[0:99] 30 | } 31 | return nil, fmt.Errorf("failed to parse metadata (%s) from event %s: %w", cont, event.ID, err) 32 | } 33 | 34 | return &meta, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/nostr/subscription_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // test if we can connect to wss://relay.damus.io and fetch a couple of random events 10 | func TestSubscribe(t *testing.T) { 11 | rl := mustRelayConnect("wss://relay.damus.io") 12 | defer rl.Close() 13 | 14 | sub, err := rl.Subscribe(context.Background(), Filters{{Kinds: []int{1}, Limit: 2}}) 15 | if err != nil { 16 | t.Errorf("subscription failed: %v", err) 17 | return 18 | } 19 | 20 | timeout := time.After(5 * time.Second) 21 | events := 0 22 | 23 | for { 24 | select { 25 | case event := <-sub.Events: 26 | if event == nil { 27 | t.Errorf("event is nil: %v", event) 28 | } 29 | events++ 30 | case <-sub.EndOfStoredEvents: 31 | goto end 32 | case <-rl.Context().Done(): 33 | t.Errorf("connection closed: %v", rl.Context().Err()) 34 | goto end 35 | case <-timeout: 36 | t.Errorf("timeout") 37 | goto end 38 | } 39 | } 40 | 41 | end: 42 | if events != 2 { 43 | t.Errorf("expected 2 events, got %d", events) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/nostr/keys.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "io" 7 | "math/big" 8 | "strings" 9 | 10 | "github.com/btcsuite/btcd/btcec/v2" 11 | "github.com/btcsuite/btcd/btcec/v2/schnorr" 12 | ) 13 | 14 | func GeneratePrivateKey() string { 15 | params := btcec.S256().Params() 16 | one := new(big.Int).SetInt64(1) 17 | 18 | b := make([]byte, params.BitSize/8+8) 19 | _, err := io.ReadFull(rand.Reader, b) 20 | if err != nil { 21 | return "" 22 | } 23 | 24 | k := new(big.Int).SetBytes(b) 25 | n := new(big.Int).Sub(params.N, one) 26 | k.Mod(k, n) 27 | k.Add(k, one) 28 | 29 | return hex.EncodeToString(k.Bytes()) 30 | } 31 | 32 | func GetPublicKey(sk string) (string, error) { 33 | b, err := hex.DecodeString(sk) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | _, pk := btcec.PrivKeyFromBytes(b) 39 | return hex.EncodeToString(schnorr.SerializePubKey(pk)), nil 40 | } 41 | 42 | func IsValidPublicKeyHex(pk string) bool { 43 | if strings.ToLower(pk) != pk { 44 | return false 45 | } 46 | dec, _ := hex.DecodeString(pk) 47 | return len(dec) == 32 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ClarkQAQ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/nostr/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 nbd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/ishell/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Abiola Ibrahim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/nostr/nip06/nip06.go: -------------------------------------------------------------------------------- 1 | package nip06 2 | 3 | import ( 4 | "encoding/hex" 5 | 6 | "github.com/tyler-smith/go-bip32" 7 | "github.com/tyler-smith/go-bip39" 8 | ) 9 | 10 | func GenerateSeedWords() (string, error) { 11 | entropy, err := bip39.NewEntropy(256) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | words, err := bip39.NewMnemonic(entropy) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | return words, nil 22 | } 23 | 24 | func SeedFromWords(words string) []byte { 25 | return bip39.NewSeed(words, "") 26 | } 27 | 28 | func PrivateKeyFromSeed(seed []byte) (string, error) { 29 | key, err := bip32.NewMasterKey(seed) 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | derivationPath := []uint32{ 35 | bip32.FirstHardenedChild + 44, 36 | bip32.FirstHardenedChild + 1237, 37 | bip32.FirstHardenedChild + 0, 38 | 0, 39 | 0, 40 | } 41 | 42 | next := key 43 | for _, idx := range derivationPath { 44 | var err error 45 | next, err = next.NewChildKey(idx) 46 | if err != nil { 47 | return "", err 48 | } 49 | } 50 | 51 | return hex.EncodeToString(next.Key), nil 52 | } 53 | 54 | func ValidateWords(words string) bool { 55 | return bip39.IsMnemonicValid(words) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/nostr/nip04/nip04_test.go: -------------------------------------------------------------------------------- 1 | package nip04 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestEncryptionAndDecryption(t *testing.T) { 9 | sharedSecret := make([]byte, 32) 10 | message := "hello hellow" 11 | 12 | ciphertext, err := Encrypt(message, sharedSecret) 13 | if err != nil { 14 | t.Errorf("failed to encrypt: %s", err.Error()) 15 | } 16 | 17 | plaintext, err := Decrypt(ciphertext, sharedSecret) 18 | if err != nil { 19 | t.Errorf("failed to decrypt: %s", err.Error()) 20 | } 21 | 22 | if message != plaintext { 23 | t.Errorf("original '%s' and decrypted '%s' messages differ", message, plaintext) 24 | } 25 | } 26 | 27 | func TestEncryptionAndDecryptionWithMultipleLengths(t *testing.T) { 28 | sharedSecret := make([]byte, 32) 29 | 30 | for i := 0; i < 150; i++ { 31 | message := strings.Repeat("a", i) 32 | 33 | ciphertext, err := Encrypt(message, sharedSecret) 34 | if err != nil { 35 | t.Errorf("failed to encrypt: %s", err.Error()) 36 | } 37 | 38 | plaintext, err := Decrypt(ciphertext, sharedSecret) 39 | if err != nil { 40 | t.Errorf("failed to decrypt: %s", err.Error()) 41 | } 42 | 43 | if message != plaintext { 44 | t.Errorf("original '%s' and decrypted '%s' messages differ", message, plaintext) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /model/storage.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type UnostrStorageData struct { 4 | Relay string `json:"relay"` // 中继器 5 | Proxy string `json:"proxy"` // 代理 6 | ConnectTimeout string `json:"connect_timeout"` // 连接超时 7 | PingInterval string `json:"ping_interval"` // ping间隔 8 | } 9 | 10 | type UnostrStorage interface { 11 | Unostr() *UnostrStorageData 12 | Write() error 13 | Read() error 14 | } 15 | 16 | type AgentStorageData struct { 17 | *UnostrStorageData 18 | PrivateKey string `json:"private_key"` // 私钥 19 | BroadcastInterval string `json:"broadcast_interval"` // 广播间隔 20 | PublicKey string `json:"-"` // 公钥 21 | } 22 | 23 | type ControlStorageData struct { 24 | *UnostrStorageData 25 | PrivateKey string `json:"private_key"` // 私钥 26 | AgentPrivateKeyList []string `json:"agent_private_key_list"` // 客户端私钥列表 27 | PublicKey string `json:"-"` // 公钥 28 | CmdTimeout string `json:"cmd_timeout"` // 命令等待超时 29 | HistoryFile string `json:"history_file"` // 历史文件 30 | ExecTimeout string `json:"exec_timeout"` // 远程命令执行超时 31 | } 32 | 33 | type Storage[T any] interface { 34 | Storage() T 35 | Write() error 36 | Read() error 37 | } 38 | -------------------------------------------------------------------------------- /pkg/ishell/CHANGES.md: -------------------------------------------------------------------------------- 1 | For now, dates (DD/MM/YYYY) are used until ishell gets stable enough to warrant tags. 2 | Attempts will be made to ensure non breaking updates as much as possible. 3 | #### 28/05/2017 4 | * Added `shell.Process(os.Args[1:]...)` for non-interactive execution 5 | * 6 | 7 | 8 | #### 07/02/2016 9 | Added multiline support to shell mode. 10 | 11 | #### 23/01/2016 12 | * Added history support. 13 | * Added tab completion support. 14 | * Added `SetHistoryPath`, `SetMultiPrompt` 15 | * Removed password masks. 16 | * **Breaking Change**: changed definition of `ReadPassword` from `(string)` to `()` 17 | * **Breaking Change**: changed name of `Shell` constructor from `NewShell` to `New` 18 | 19 | #### 13/07/2015 20 | * Added `ClearScreen` method. 21 | * Added `clear` to default commands. 22 | 23 | #### 12/07/2015: 24 | * Added `PrintCommands`, `Commands` and `ShowPrompt` methods. 25 | * Added default `exit` and `help` commands. 26 | * **Breaking Change**: changed return values of `ReadLine` from `(string, error)` to `string.` 27 | * **Breaking Change**: changed definition of `CmdFunc` from `(cmd string, args []string)` to `(args ...String)` to remove redundant command being passed. 28 | * Added multiline input support. 29 | * Added case insensitive command support. 30 | 31 | #### 11/07/2015: 32 | * Initial version. 33 | -------------------------------------------------------------------------------- /pkg/nostr/nip10/nip10.go: -------------------------------------------------------------------------------- 1 | package nip10 2 | 3 | import "nrat/pkg/nostr" 4 | 5 | func GetThreadRoot(tags nostr.Tags) *nostr.Tag { 6 | for _, tag := range tags { 7 | if len(tag) >= 4 && tag[0] == "e" && tag[3] == "root" { 8 | return &tag 9 | } 10 | } 11 | 12 | return tags.GetFirst([]string{"e", ""}) 13 | } 14 | 15 | func GetImmediateReply(tags nostr.Tags) *nostr.Tag { 16 | var root *nostr.Tag 17 | var lastE *nostr.Tag 18 | 19 | for i := len(tags) - 1; i >= 0; i-- { 20 | tag := tags[i] 21 | 22 | if len(tag) < 2 { 23 | continue 24 | } 25 | if tag[0] != "e" { 26 | continue 27 | } 28 | 29 | if len(tag) >= 4 { 30 | if tag[3] == "reply" { 31 | return &tag 32 | } 33 | if tag[3] == "root" { 34 | // will be used as our first fallback 35 | root = &tag 36 | continue 37 | } 38 | if tag[3] == "mention" { 39 | // this invalidates this tag as a second fallback mechanism (clients that don't add markers) 40 | continue 41 | } 42 | } 43 | 44 | lastE = &tag // will be used as our second fallback (clients that don't add markers) 45 | } 46 | 47 | // if we reached this point we don't have a "reply", but if we have a "root" 48 | // that means this event is a direct reply to the root 49 | if root != nil { 50 | return root 51 | } 52 | 53 | // if we reached this point and we have at least one "e" we'll use that (the last) 54 | return lastE 55 | } 56 | -------------------------------------------------------------------------------- /pkg/nostr/nip11/fetch.go: -------------------------------------------------------------------------------- 1 | package nip11 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "time" 13 | ) 14 | 15 | 16 | // Fetch fetches the NIP-11 RelayInformationDocument. 17 | func Fetch(ctx context.Context, u string) (info *RelayInformationDocument, err error) { 18 | if _, ok := ctx.Deadline(); !ok { 19 | // if no timeout is set, force it to 7 seconds 20 | var cancel context.CancelFunc 21 | ctx, cancel = context.WithTimeout(ctx, 7*time.Second) 22 | defer cancel() 23 | } 24 | 25 | // normalize URL to start with http:// or https:// 26 | if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "ws") { 27 | u = "wss://" + u 28 | } 29 | p, err := url.Parse(u) 30 | if err != nil { 31 | return nil, fmt.Errorf("Cannot parse url: %s", u) 32 | } 33 | if p.Scheme == "ws" { 34 | p.Scheme = "http" 35 | } else if p.Scheme == "wss" { 36 | p.Scheme = "https" 37 | } 38 | p.Path = strings.TrimRight(p.Path, "/") 39 | 40 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.String(), nil) 41 | 42 | // add the NIP-11 header 43 | req.Header.Add("Accept", "application/nostr+json") 44 | 45 | // send the request 46 | resp, err := http.DefaultClient.Do(req) 47 | if err != nil { 48 | return nil, err 49 | } 50 | info = &RelayInformationDocument{} 51 | dec := json.NewDecoder(resp.Body) 52 | err = dec.Decode(info) 53 | return info, err 54 | } 55 | -------------------------------------------------------------------------------- /pkg/nostr/tag_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTagHelpers(t *testing.T) { 8 | tags := Tags{ 9 | Tag{"x"}, 10 | Tag{"p", "abcdef", "wss://x.com"}, 11 | Tag{"p", "123456", "wss://y.com"}, 12 | Tag{"e", "eeeeee"}, 13 | Tag{"e", "ffffff"}, 14 | } 15 | 16 | if tags.GetFirst([]string{"x"}) == nil { 17 | t.Error("failed to get existing prefix") 18 | } 19 | if tags.GetFirst([]string{"x", ""}) != nil { 20 | t.Error("got with wrong prefix") 21 | } 22 | if tags.GetFirst([]string{"p", "abcdef", "wss://"}) == nil { 23 | t.Error("failed to get with existing prefix") 24 | } 25 | if tags.GetFirst([]string{"p", "abcdef", ""}) == nil { 26 | t.Error("failed to get with existing prefix (blank last string)") 27 | } 28 | if (*(tags.GetLast([]string{"e"})))[1] != "ffffff" { 29 | t.Error("failed to get last") 30 | } 31 | 32 | if len(tags.GetAll([]string{"e", ""})) != 2 { 33 | t.Error("failed to get all") 34 | } 35 | 36 | if len(tags.AppendUnique(Tag{"e", "ffffff"})) != 5 { 37 | t.Error("append unique changed the array size when existed") 38 | } 39 | if len(tags.AppendUnique(Tag{"e", "bbbbbb"})) != 6 { 40 | t.Error("append unique failed to append when didn't exist") 41 | } 42 | if tags.AppendUnique(Tag{"e", "eeeeee"})[4][1] != "ffffff" { 43 | t.Error("append unique changed the order") 44 | } 45 | if tags.AppendUnique(Tag{"e", "eeeeee"})[3][1] != "eeeeee" { 46 | t.Error("append unique changed the order") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/ishell/completer.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/flynn-archive/go-shlex" 7 | ) 8 | 9 | type iCompleter struct { 10 | cmd *Cmd 11 | disabled func() bool 12 | } 13 | 14 | func (ic iCompleter) Do(line []rune, pos int) (newLine [][]rune, length int) { 15 | if ic.disabled != nil && ic.disabled() { 16 | return nil, len(line) 17 | } 18 | var words []string 19 | if w, err := shlex.Split(string(line)); err == nil { 20 | words = w 21 | } else { 22 | // fall back 23 | words = strings.Fields(string(line)) 24 | } 25 | 26 | var cWords []string 27 | prefix := "" 28 | if len(words) > 0 && pos > 0 && line[pos-1] != ' ' { 29 | prefix = words[len(words)-1] 30 | cWords = ic.getWords(prefix, words[:len(words)-1]) 31 | } else { 32 | cWords = ic.getWords(prefix, words) 33 | } 34 | 35 | var suggestions [][]rune 36 | for _, w := range cWords { 37 | if strings.HasPrefix(w, prefix) { 38 | suggestions = append(suggestions, []rune(strings.TrimPrefix(w, prefix))) 39 | } 40 | } 41 | if len(suggestions) == 1 && prefix != "" && string(suggestions[0]) == "" { 42 | suggestions = [][]rune{[]rune(" ")} 43 | } 44 | return suggestions, len(prefix) 45 | } 46 | 47 | func (ic iCompleter) getWords(prefix string, w []string) (s []string) { 48 | cmd, args := ic.cmd.FindCmd(w) 49 | if cmd == nil { 50 | cmd, args = ic.cmd, w 51 | } 52 | if cmd.CompleterWithPrefix != nil { 53 | return cmd.CompleterWithPrefix(prefix, args) 54 | } 55 | if cmd.Completer != nil { 56 | return cmd.Completer(args) 57 | } 58 | for k := range cmd.children { 59 | s = append(s, k) 60 | } 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /pkg/ishell/context.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | // Context is an ishell context. It embeds ishell.Actions. 4 | type Context struct { 5 | contextValues 6 | progressBar ProgressBar 7 | err error 8 | 9 | // Args is command arguments. 10 | Args []string 11 | 12 | // RawArgs is unprocessed command arguments. 13 | RawArgs []string 14 | 15 | // Cmd is the currently executing command. This is empty for NotFound and Interrupt. 16 | Cmd Cmd 17 | 18 | Actions 19 | } 20 | 21 | // Err informs ishell that an error occurred in the current 22 | // function. 23 | func (c *Context) Err(err error) { 24 | c.err = err 25 | } 26 | 27 | // ProgressBar returns the progress bar for the current shell context. 28 | func (c *Context) ProgressBar() ProgressBar { 29 | return c.progressBar 30 | } 31 | 32 | // contextValues is the map for values in the context. 33 | type contextValues map[string]interface{} 34 | 35 | // Get returns the value associated with this context for key, or nil 36 | // if no value is associated with key. Successive calls to Get with 37 | // the same key returns the same result. 38 | func (c contextValues) Get(key string) interface{} { 39 | return c[key] 40 | } 41 | 42 | // Set sets the key in this context to value. 43 | func (c *contextValues) Set(key string, value interface{}) { 44 | if *c == nil { 45 | *c = make(map[string]interface{}) 46 | } 47 | (*c)[key] = value 48 | } 49 | 50 | // Del deletes key and its value in this context. 51 | func (c contextValues) Del(key string) { 52 | delete(c, key) 53 | } 54 | 55 | // Keys returns all keys in the context. 56 | func (c contextValues) Keys() (keys []string) { 57 | for key := range c { 58 | keys = append(keys, key) 59 | } 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /pkg/nostr/nip26/nip26_test.go: -------------------------------------------------------------------------------- 1 | package nip26 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "nrat/pkg/nostr" 8 | ) 9 | 10 | func TestDelegateSign(t *testing.T) { 11 | since := time.Unix(1600000000, 0) 12 | until := time.Unix(1600000100, 0) 13 | delegator_secret_key, delegatee_secret_key := "3f0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459da", "e9142f724955c5854de36324dab0434f97b15ec6b33464d56ebe491e3f559d1b" 14 | delegatee_pubkey, _ := nostr.GetPublicKey(delegatee_secret_key) 15 | d1, err := CreateToken(delegator_secret_key, delegatee_pubkey, []int{1, 2, 3}, &since, &until) 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | ev := &nostr.Event{} 20 | ev.CreatedAt = nostr.Timestamp(1600000050) 21 | ev.Content = "hello world" 22 | ev.Kind = 1 23 | if err != nil { 24 | t.Error(err) 25 | } 26 | d2, err := Import(d1.Tag(), delegatee_pubkey) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | 31 | if err = DelegatedSign(ev, d2, delegatee_secret_key); err != nil { 32 | t.Error(err) 33 | } 34 | if ok, err := CheckDelegation(ev); err != nil || ok == false { 35 | t.Error(err) 36 | } 37 | 38 | tag := []string{"delegation", "9ea72be3fcfe38103195a41b67b6f96c14ed92d2091d6d9eb8166a5c27b0c35d", "kind=1&kind=2&kind=3&created_at>1600000000", "8432b8c86f789c2783ef3becb0fabf4def6031c6a615fa7a622f31329d80ed1b2a79ab753c0462f1440503c94e1829158a3a854a1d418ad256ae2cf8aa19fa9a"} 39 | d3, err := Import(tag, delegatee_pubkey) 40 | if err != nil { 41 | t.Error(err) 42 | } 43 | 44 | ev.Tags = nil 45 | if err = DelegatedSign(ev, d3, delegatee_secret_key); err != nil { 46 | t.Error(err) 47 | } 48 | if ok, err := d3.Parse(ev); err != nil || ok == false { 49 | t.Error(err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/nostr/event_extra.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | // SetExtra sets an out-of-the-spec value under the given key into the event object. 4 | func (evt *Event) SetExtra(key string, value any) { 5 | if evt.extra == nil { 6 | evt.extra = make(map[string]any) 7 | } 8 | evt.extra[key] = value 9 | } 10 | 11 | // GetExtra tries to get a value under the given key that may be present in the event object 12 | // but is hidden in the basic type since it is out of the spec. 13 | func (evt Event) GetExtra(key string) any { 14 | return evt.extra[key] 15 | } 16 | 17 | // GetExtraString is like [Event.GetExtra], but only works if the value is a string, 18 | // otherwise returns the zero-value. 19 | func (evt Event) GetExtraString(key string) string { 20 | ival, ok := evt.extra[key] 21 | if !ok { 22 | return "" 23 | } 24 | val, ok := ival.(string) 25 | if !ok { 26 | return "" 27 | } 28 | return val 29 | } 30 | 31 | // GetExtraNumber is like [Event.GetExtra], but only works if the value is a float64, 32 | // otherwise returns the zero-value. 33 | func (evt Event) GetExtraNumber(key string) float64 { 34 | ival, ok := evt.extra[key] 35 | if !ok { 36 | return 0 37 | } 38 | 39 | switch val := ival.(type) { 40 | case float64: 41 | return val 42 | case int: 43 | return float64(val) 44 | case int64: 45 | return float64(val) 46 | } 47 | 48 | return 0 49 | } 50 | 51 | // GetExtraBoolean is like [Event.GetExtra], but only works if the value is a boolean, 52 | // otherwise returns the zero-value. 53 | func (evt Event) GetExtraBoolean(key string) bool { 54 | ival, ok := evt.extra[key] 55 | if !ok { 56 | return false 57 | } 58 | val, ok := ival.(bool) 59 | if !ok { 60 | return false 61 | } 62 | return val 63 | } 64 | -------------------------------------------------------------------------------- /pkg/nostr/sdk/input.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | 7 | "nrat/pkg/nostr" 8 | 9 | "nrat/pkg/nostr/nip05" 10 | "nrat/pkg/nostr/nip19" 11 | ) 12 | 13 | // InputToProfile turns any npub/nprofile/hex/nip05 input into a ProfilePointer (or nil) 14 | func InputToProfile(ctx context.Context, input string) *nostr.ProfilePointer { 15 | // handle if it is a hex string 16 | if len(input) == 64 { 17 | if _, err := hex.DecodeString(input); err == nil { 18 | return &nostr.ProfilePointer{PublicKey: input} 19 | } 20 | } 21 | 22 | // handle nip19 codes, if that's the case 23 | prefix, data, _ := nip19.Decode(input) 24 | switch prefix { 25 | case "npub": 26 | input = data.(string) 27 | return &nostr.ProfilePointer{PublicKey: input} 28 | case "nprofile": 29 | pp := data.(nostr.ProfilePointer) 30 | return &pp 31 | } 32 | 33 | // handle nip05 ids, if that's the case 34 | pp, _ := nip05.QueryIdentifier(ctx, input) 35 | if pp != nil { 36 | return pp 37 | } 38 | 39 | return nil 40 | } 41 | 42 | // InputToEventPointer turns any note/nevent/hex input into a EventPointer (or nil) 43 | func InputToEventPointer(input string) *nostr.EventPointer { 44 | // handle if it is a hex string 45 | if len(input) == 64 { 46 | if _, err := hex.DecodeString(input); err == nil { 47 | return &nostr.EventPointer{ID: input} 48 | } 49 | } 50 | 51 | // handle nip19 codes, if that's the case 52 | prefix, data, _ := nip19.Decode(input) 53 | switch prefix { 54 | case "note": 55 | input = data.(string) 56 | return &nostr.EventPointer{ID: input} 57 | case "nevent": 58 | ep := data.(nostr.EventPointer) 59 | return &ep 60 | } 61 | 62 | // handle nip05 ids, if that's the case 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module nrat 2 | 3 | go 1.20 4 | 5 | replace uw => github.com/ClarkQAQ/uw v0.0.0-20230911090314-9617b4352e12 6 | 7 | require ( 8 | github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20230201052002-6c5833b989be 9 | github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db 10 | github.com/atotto/clipboard v0.1.4 11 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 12 | github.com/btcsuite/btcd/btcutil v1.1.3 13 | github.com/fatih/color v1.15.0 14 | github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 15 | github.com/gobwas/httphead v0.1.0 16 | github.com/gobwas/ws v1.2.1 17 | github.com/mailru/easyjson v0.7.7 18 | github.com/nbd-wtf/go-nostr v0.18.7 19 | github.com/tidwall/gjson v1.14.4 20 | github.com/tyler-smith/go-bip32 v1.0.0 21 | github.com/tyler-smith/go-bip39 v1.1.0 22 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 23 | golang.org/x/net v0.36.0 24 | uw v0.0.0-00010101000000-000000000000 25 | ) 26 | 27 | require ( 28 | github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect 29 | github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect 30 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect 31 | github.com/chzyer/test v1.0.0 // indirect 32 | github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect 33 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect 34 | github.com/gobwas/pool v0.2.1 // indirect 35 | github.com/josharian/intern v1.0.0 // indirect 36 | github.com/mattn/go-colorable v0.1.13 // indirect 37 | github.com/mattn/go-isatty v0.0.17 // indirect 38 | github.com/puzpuzpuz/xsync v1.5.2 // indirect 39 | github.com/tidwall/match v1.1.1 // indirect 40 | github.com/tidwall/pretty v1.2.0 // indirect 41 | golang.org/x/crypto v0.35.0 // indirect 42 | golang.org/x/sys v0.30.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NRAT 2 | 3 | > 一个基于 Nostr 去中心的匿名远程控制工具 4 | 5 | > A decentralized anonymous remote control tool based on Nostr 6 | 7 | > Децентрализованный анонимный инструмент удаленного управления на основе Nostr 8 | 9 | ## 介绍 10 | 11 | Nrat 是一个基于 Nostr 去中心的匿名远程控制工具, 使用 Nostr 的匿名通信特性, 使得 Nrat 可以在不暴露服务器 IP 的情况下进行远程控制. 12 | 13 | 并且由于愈发健壮的 Nostr 网络, 在全球范围内的节点都可以进行通信, 相较于传统的 IRC 中继网络, Nrat 的通信更加稳定, 延迟更低, 并且支持非对称加密, 使得通信更加安全. 14 | 15 | Nrat 由两个部分组成, 一个是控制端, 一个是被控端. 16 | 17 | control: 控制端用于控制被控端, 并且在没有 Golang 语言环境的情况下修补被控端二进制嵌入配置文件数据. 18 | agent: 被控端用于接收控制端的指令, 并定期通过 meta 广播自身的信息, 以便控制端发现. 19 | 20 | 21 | #### 已知问题 22 | 23 | 1. 在 Windows 平台 cd 命令不能正常处理路径 24 | 2. 文件上传下载没有切片和断点续传, 在某些节点上面可能会严格限制报文大小, 导致不能传输大文件... 25 | 26 | ## 功能 27 | 28 | 1. 文件管理 (文件增删改查) 29 | 2. 远程执行命令 30 | 3. 剪切板 31 | 4. 截图 (WIP) 32 | 33 | ## 使用 34 | 35 | ### 控制端 36 | 37 | 编译或者在 [Release](https://github.com/ClarkQAQ/nrat/releases) 中下载控制端二进制文件, 然后运行即可. 38 | 39 | ### 被控端 40 | 41 | 编译或者在 [Release](https://github.com/ClarkQAQ/nrat/releases) 中下载被控端二进制文件, 然后使用控制端的 `fix ` 命令修补并嵌入配置文件进被控端二进制文件, 最后运行被控端即可, 被控端会自动连接 Nostr 网络并广播自身的信息. 并且被控端密钥也会被写入控制端的配置文件中, 以便控制端连接被控端. 42 | 43 | ## 指令 44 | 45 | 1. `help`: 显示帮助信息 46 | 2. `fix `: 修补被控端二进制文件并嵌入配置文件 47 | 3. `agent`: 显示配置文件中的被控端信息 48 | 4. `connect | cc `: 选择或者直接连接被控端 49 | 5. `list | ls `: 列出被控端当前的文件列表 50 | 6. `chdir | cd `: 切换被控端当前的目录 51 | 7. `mkdir `: 在被控端当前的目录下创建目录 52 | 8. `remove | rm `: 删除被控端当前的目录或者文件 53 | 9. `move | mv `: 重命名被控端当前的目录或者文件 54 | 10. `upload | up `: 上传本地文件到被控端 55 | 11. `download | dl `: 下载被控端文件到本地 56 | 12. `exec `: 在被控端执行命令 57 | 13. `info`: 显示被控端信息, 添加任意参数显示完整私钥 58 | 59 | ## 最后 60 | 61 | Happy Hacking! 62 | -------------------------------------------------------------------------------- /pkg/nostr/utils.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/exp/constraints" 7 | ) 8 | 9 | func similar[E constraints.Ordered](as, bs []E) bool { 10 | if len(as) != len(bs) { 11 | return false 12 | } 13 | 14 | for _, a := range as { 15 | for _, b := range bs { 16 | if b == a { 17 | goto next 18 | } 19 | } 20 | // didn't find a B that corresponded to the current A 21 | return false 22 | 23 | next: 24 | continue 25 | } 26 | 27 | return true 28 | } 29 | 30 | func containsPrefixOf(haystack []string, needle string) bool { 31 | for _, hay := range haystack { 32 | if strings.HasPrefix(needle, hay) { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | 39 | // Escaping strings for JSON encoding according to RFC8259. 40 | // Also encloses result in quotation marks "". 41 | func escapeString(dst []byte, s string) []byte { 42 | dst = append(dst, '"') 43 | for i := 0; i < len(s); i++ { 44 | c := s[i] 45 | switch { 46 | case c == '"': 47 | // quotation mark 48 | dst = append(dst, []byte{'\\', '"'}...) 49 | case c == '\\': 50 | // reverse solidus 51 | dst = append(dst, []byte{'\\', '\\'}...) 52 | case c >= 0x20: 53 | // default, rest below are control chars 54 | dst = append(dst, c) 55 | case c == 0x08: 56 | dst = append(dst, []byte{'\\', 'b'}...) 57 | case c < 0x09: 58 | dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...) 59 | case c == 0x09: 60 | dst = append(dst, []byte{'\\', 't'}...) 61 | case c == 0x0a: 62 | dst = append(dst, []byte{'\\', 'n'}...) 63 | case c == 0x0c: 64 | dst = append(dst, []byte{'\\', 'f'}...) 65 | case c == 0x0d: 66 | dst = append(dst, []byte{'\\', 'r'}...) 67 | case c < 0x10: 68 | dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...) 69 | case c < 0x1a: 70 | dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...) 71 | case c < 0x20: 72 | dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...) 73 | } 74 | } 75 | dst = append(dst, '"') 76 | return dst 77 | } 78 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "uw/uboot" 7 | ) 8 | 9 | // 断言 Context 10 | func UbootGetAssert[T any](c *uboot.Context, key string) (T, bool) { 11 | v, ok := c.Load(key) 12 | if !ok || v == nil { 13 | var empty T 14 | return empty, false 15 | } 16 | 17 | ret, ok := v.(T) 18 | return ret, ok 19 | } 20 | 21 | func CutMore(s string, n int) string { 22 | if len(s) < 2*n { 23 | return s 24 | } 25 | 26 | return s[:n] + "..." + s[len(s)-n:] 27 | } 28 | 29 | func ReadEmbedData(b []byte, none byte, startMagic, endMagic []byte) ([]byte, error) { 30 | startIndex, endIndex := bytes.Index(b, startMagic), bytes.Index(b, endMagic) 31 | if startIndex < 0 || startIndex+len(startMagic) > len(b) { 32 | return nil, errors.New("start magic not found") 33 | } 34 | if endIndex < 0 { 35 | return nil, errors.New("end magic not found") 36 | } 37 | 38 | b = b[startIndex+len(startMagic) : endIndex] 39 | b = bytes.TrimSpace(b) 40 | 41 | for i := 0; i < len(b); i++ { 42 | if b[i] != none { 43 | b = b[i:] 44 | break 45 | } 46 | } 47 | 48 | for i := len(b) - 1; i >= 0; i-- { 49 | if b[i] != none { 50 | b = b[:i+1] 51 | break 52 | } 53 | } 54 | 55 | return b, nil 56 | } 57 | 58 | func WriteEmbedData(b []byte, none byte, startMagic, endMagic, data []byte) ([]byte, error) { 59 | startIndex, endIndex := bytes.Index(b, startMagic), bytes.Index(b, endMagic) 60 | if startIndex < 0 || startIndex+len(startMagic) > len(b) { 61 | return nil, errors.New("start magic not found") 62 | } 63 | if endIndex < 0 { 64 | return nil, errors.New("end magic not found") 65 | } 66 | 67 | if len(data) > endIndex-startIndex-len(startMagic) { 68 | return nil, errors.New("data too long") 69 | } 70 | 71 | if len(data) < endIndex-startIndex-len(startMagic) { 72 | data = append(data, bytes.Repeat([]byte{none}, 73 | endIndex-startIndex-len(startMagic)-len(data))...) 74 | } 75 | 76 | if n := copy(b[startIndex+len(startMagic):endIndex], data); n != len(data) { 77 | return nil, errors.New("copy data failed") 78 | } 79 | 80 | return b, nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/nostr/nip42/nip42.go: -------------------------------------------------------------------------------- 1 | package nip42 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | "time" 7 | 8 | "nrat/pkg/nostr" 9 | ) 10 | 11 | // CreateUnsignedAuthEvent creates an event which should be sent via an "AUTH" command. 12 | // If the authentication succeeds, the user will be authenticated as pubkey. 13 | func CreateUnsignedAuthEvent(challenge, pubkey, relayURL string) nostr.Event { 14 | return nostr.Event{ 15 | PubKey: pubkey, 16 | CreatedAt: nostr.Now(), 17 | Kind: 22242, 18 | Tags: nostr.Tags{ 19 | nostr.Tag{"relay", relayURL}, 20 | nostr.Tag{"challenge", challenge}, 21 | }, 22 | Content: "", 23 | } 24 | } 25 | 26 | // helper function for ValidateAuthEvent 27 | func parseUrl(input string) (*url.URL, error) { 28 | return url.Parse( 29 | strings.ToLower( 30 | strings.TrimSuffix(input, "/"), 31 | ), 32 | ) 33 | } 34 | 35 | // ValidateAuthEvent checks whether event is a valid NIP-42 event for given challenge and relayURL. 36 | // The result of the validation is encoded in the ok bool. 37 | func ValidateAuthEvent(event *nostr.Event, challenge string, relayURL string) (pubkey string, ok bool) { 38 | if event.Kind != 22242 { 39 | return "", false 40 | } 41 | 42 | if event.Tags.GetFirst([]string{"challenge", challenge}) == nil { 43 | return "", false 44 | } 45 | 46 | expected, err := parseUrl(relayURL) 47 | if err != nil { 48 | return "", false 49 | } 50 | 51 | found, err := parseUrl(event.Tags.GetFirst([]string{"relay", ""}).Value()) 52 | if err != nil { 53 | return "", false 54 | } 55 | 56 | if expected.Scheme != found.Scheme || 57 | expected.Host != found.Host || 58 | expected.Path != found.Path { 59 | return "", false 60 | } 61 | 62 | now := time.Now() 63 | if event.CreatedAt.Time().After(now.Add(10*time.Minute)) || event.CreatedAt.Time().Before(now.Add(-10*time.Minute)) { 64 | return "", false 65 | } 66 | 67 | // save for last, as it is most expensive operation 68 | // no need to check returned error, since ok == true implies err == nil. 69 | if ok, _ := event.CheckSignature(); !ok { 70 | return "", false 71 | } 72 | 73 | return event.PubKey, true 74 | } 75 | -------------------------------------------------------------------------------- /pkg/nostr/nip11/types.go: -------------------------------------------------------------------------------- 1 | package nip11 2 | 3 | type RelayInformationDocument struct { 4 | Name string `json:"name"` 5 | Description string `json:"description"` 6 | PubKey string `json:"pubkey"` 7 | Contact string `json:"contact"` 8 | SupportedNIPs []int `json:"supported_nips"` 9 | Software string `json:"software"` 10 | Version string `json:"version"` 11 | 12 | Limitation *RelayLimitationDocument `json:"limitation,omitempty"` 13 | RelayCountries []string `json:"relay_countries,omitempty"` 14 | LanguageTags []string `json:"language_tags,omitempty"` 15 | Tags []string `json:"tags,omitempty"` 16 | PostingPolicy string `json:"posting_policy,omitempty"` 17 | PaymentsURL string `json:"payments_url,omitempty"` 18 | Fees *RelayFeesDocument `json:"fees,omitempty"` 19 | } 20 | 21 | type RelayLimitationDocument struct { 22 | MaxMessageLength int `json:"max_message_length,omitempty"` 23 | MaxSubscriptions int `json:"max_subscriptions,omitempty"` 24 | MaxFilters int `json:"max_filters,omitempty"` 25 | MaxLimit int `json:"max_limit,omitempty"` 26 | MaxSubidLength int `json:"max_subid_length,omitempty"` 27 | MinPrefix int `json:"min_prefix,omitempty"` 28 | MaxEventTags int `json:"max_event_tags,omitempty"` 29 | MaxContentLength int `json:"max_content_length,omitempty"` 30 | MinPowDifficulty int `json:"min_pow_difficulty,omitempty"` 31 | AuthRequired bool `json:"auth_required"` 32 | PaymentRequired bool `json:"payment_required"` 33 | } 34 | 35 | type RelayFeesDocument struct { 36 | Admission []struct { 37 | Amount int `json:"amount"` 38 | Unit string `json:"unit"` 39 | } `json:"admission,omitempty"` 40 | Subscription []struct { 41 | Amount int `json:"amount"` 42 | Unit string `json:"unit"` 43 | Period int `json:"period"` 44 | } `json:"subscription,omitempty"` 45 | Publication []struct { 46 | Kinds []int `json:"kinds"` 47 | Amount int `json:"amount"` 48 | Unit string `json:"unit"` 49 | } `json:"publication,omitempty"` 50 | } 51 | -------------------------------------------------------------------------------- /pkg/nostr/nip05/nip05.go: -------------------------------------------------------------------------------- 1 | package nip05 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | 11 | "nrat/pkg/nostr" 12 | ) 13 | 14 | type ( 15 | name2KeyMap map[string]string 16 | key2RelaysMap map[string][]string 17 | ) 18 | 19 | type WellKnownResponse struct { 20 | Names name2KeyMap `json:"names"` // NIP-05 21 | Relays key2RelaysMap `json:"relays"` // NIP-35 22 | } 23 | 24 | func QueryIdentifier(ctx context.Context, fullname string) (*nostr.ProfilePointer, error) { 25 | spl := strings.Split(fullname, "@") 26 | 27 | var name, domain string 28 | switch len(spl) { 29 | case 1: 30 | name = "_" 31 | domain = spl[0] 32 | case 2: 33 | name = spl[0] 34 | domain = spl[1] 35 | default: 36 | return nil, fmt.Errorf("not a valid nip-05 identifier") 37 | } 38 | 39 | if strings.Index(domain, ".") == -1 { 40 | return nil, fmt.Errorf("hostname doesn't have a dot") 41 | } 42 | 43 | req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/.well-known/nostr.json?name=%s", domain, name), nil) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to create a request: %w", err) 46 | } 47 | 48 | client := &http.Client{ 49 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 50 | return http.ErrUseLastResponse 51 | }, 52 | } 53 | res, err := client.Do(req) 54 | if err != nil { 55 | return nil, fmt.Errorf("request failed: %w", err) 56 | } 57 | 58 | var result WellKnownResponse 59 | if err := json.NewDecoder(res.Body).Decode(&result); err != nil { 60 | return nil, fmt.Errorf("failed to decode json response: %w", err) 61 | } 62 | 63 | pubkey, ok := result.Names[name] 64 | if !ok { 65 | return nil, nil 66 | } 67 | 68 | if len(pubkey) == 64 { 69 | if _, err := hex.DecodeString(pubkey); err != nil { 70 | return nil, nil 71 | } 72 | } 73 | 74 | relays, _ := result.Relays[pubkey] 75 | 76 | return &nostr.ProfilePointer{ 77 | PublicKey: pubkey, 78 | Relays: relays, 79 | }, nil 80 | } 81 | 82 | func NormalizeIdentifier(fullname string) string { 83 | if strings.HasPrefix(fullname, "_@") { 84 | return fullname[2:] 85 | } 86 | 87 | return fullname 88 | } 89 | -------------------------------------------------------------------------------- /cmd/control/internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "strings" 10 | "uw/uboot" 11 | "uw/ulog" 12 | 13 | "nrat/pkg/nostr" 14 | 15 | "nrat/model" 16 | ) 17 | 18 | var ( 19 | storagePath string = ".control.json" 20 | //go:embed default.json 21 | defaultCfgJson []byte 22 | ) 23 | 24 | func StorageUint(c *uboot.Context) (e error) { 25 | s := &Storage{ 26 | storageData: &model.ControlStorageData{}, 27 | } 28 | 29 | if e := s.Read(); e != nil { 30 | ulog.Warn("read storage failed: %s", e) 31 | } 32 | 33 | c.Printf("public key: %s", s.Storage().PublicKey) 34 | 35 | c.Set("storage", s) 36 | return nil 37 | } 38 | 39 | type Storage struct { 40 | storageData *model.ControlStorageData 41 | } 42 | 43 | func (s *Storage) Storage() *model.ControlStorageData { 44 | return s.storageData 45 | } 46 | 47 | func (s *Storage) Unostr() *model.UnostrStorageData { 48 | return s.storageData.UnostrStorageData 49 | } 50 | 51 | func (s *Storage) Write() error { 52 | b, e := json.MarshalIndent(s.storageData, "", " ") 53 | if e != nil { 54 | return fmt.Errorf("marshal cfg failed: %s", e) 55 | } 56 | 57 | if e := os.WriteFile(storagePath, b, 0o644); e != nil { 58 | return fmt.Errorf("write storage file failed: %s", e) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (s *Storage) Read() error { 65 | b, e := os.ReadFile(storagePath) 66 | if e != nil && !os.IsNotExist(e) { 67 | return fmt.Errorf("read storage file failed: %s", e) 68 | } 69 | 70 | b = bytes.TrimSpace(b) 71 | 72 | if len(b) < 1 || !bytes.HasPrefix(b, []byte("{")) || 73 | !bytes.HasSuffix(b, []byte("}")) { 74 | b = defaultCfgJson 75 | } 76 | 77 | if e := json.Unmarshal(b, s.storageData); e != nil { 78 | return fmt.Errorf("unmarshal storage file failed: %s", e) 79 | } 80 | 81 | if strings.TrimSpace(s.storageData.PrivateKey) == "" { 82 | s.storageData.PrivateKey = nostr.GeneratePrivateKey() 83 | if e := s.Write(); e != nil { 84 | return fmt.Errorf("write cfg failed: %s", e) 85 | } 86 | 87 | ulog.Info("generated private key: %s", s.storageData.PrivateKey) 88 | } 89 | 90 | if s.storageData.PublicKey, e = nostr. 91 | GetPublicKey(s.storageData.PrivateKey); e != nil { 92 | return fmt.Errorf("get public key failed: %s", e) 93 | } 94 | 95 | return s.Write() 96 | } 97 | -------------------------------------------------------------------------------- /pkg/ishell/reader.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/abiosoft/readline" 9 | ) 10 | 11 | type ( 12 | lineString struct { 13 | line string 14 | err error 15 | } 16 | 17 | shellReader struct { 18 | scanner *readline.Instance 19 | consumers chan lineString 20 | reading bool 21 | readingMulti bool 22 | buf *bytes.Buffer 23 | prompt string 24 | multiPrompt string 25 | showPrompt bool 26 | completer readline.AutoCompleter 27 | defaultInput string 28 | sync.Mutex 29 | } 30 | ) 31 | 32 | // rlPrompt returns the proper prompt for readline based on showPrompt and 33 | // prompt members. 34 | func (s *shellReader) rlPrompt() string { 35 | if s.showPrompt { 36 | if s.readingMulti { 37 | return s.multiPrompt 38 | } 39 | return s.prompt 40 | } 41 | return "" 42 | } 43 | 44 | func (s *shellReader) readPasswordErr() (string, error) { 45 | prompt := "" 46 | if s.buf.Len() > 0 { 47 | prompt = s.buf.String() 48 | s.buf.Truncate(0) 49 | } 50 | password, err := s.scanner.ReadPassword(prompt) 51 | return string(password), err 52 | } 53 | 54 | func (s *shellReader) readPassword() string { 55 | password, _ := s.readPasswordErr() 56 | return password 57 | } 58 | 59 | func (s *shellReader) setMultiMode(use bool) { 60 | s.readingMulti = use 61 | } 62 | 63 | func (s *shellReader) readLine(consumer chan lineString) { 64 | s.Lock() 65 | defer s.Unlock() 66 | 67 | // already reading 68 | if s.reading { 69 | return 70 | } 71 | s.reading = true 72 | // start reading 73 | 74 | // detect if print is called to 75 | // prevent readline lib from clearing line. 76 | // use the last line as prompt. 77 | // TODO find better way. 78 | shellPrompt := s.prompt 79 | prompt := s.rlPrompt() 80 | if s.buf.Len() > 0 { 81 | lines := strings.Split(s.buf.String(), "\n") 82 | if p := lines[len(lines)-1]; strings.TrimSpace(p) != "" { 83 | prompt = p 84 | } 85 | s.buf.Truncate(0) 86 | } 87 | 88 | // use printed statement as prompt 89 | s.scanner.SetPrompt(prompt) 90 | 91 | line, err := s.scanner.ReadlineWithDefault(s.defaultInput) 92 | 93 | // reset prompt 94 | s.scanner.SetPrompt(shellPrompt) 95 | 96 | ls := lineString{string(line), err} 97 | consumer <- ls 98 | s.reading = false 99 | } 100 | -------------------------------------------------------------------------------- /pkg/nostr/nip13/nip13.go: -------------------------------------------------------------------------------- 1 | // Package nip13 implements NIP-13 2 | // See https://github.com/nostr-protocol/nips/blob/master/13.md for details. 3 | package nip13 4 | 5 | import ( 6 | "encoding/hex" 7 | "errors" 8 | "math/bits" 9 | "strconv" 10 | "time" 11 | 12 | "nrat/pkg/nostr" 13 | ) 14 | 15 | var ( 16 | ErrDifficultyTooLow = errors.New("nip13: insufficient difficulty") 17 | ErrGenerateTimeout = errors.New("nip13: generating proof of work took too long") 18 | ) 19 | 20 | // Difficulty counts the number of leading zero bits in an event ID. 21 | // It returns a negative number if the event ID is malformed. 22 | func Difficulty(eventID string) int { 23 | if len(eventID) != 64 { 24 | return -1 25 | } 26 | var zeros int 27 | for i := 0; i < 64; i += 2 { 28 | if eventID[i:i+2] == "00" { 29 | zeros += 8 30 | continue 31 | } 32 | var b [1]byte 33 | if _, err := hex.Decode(b[:], []byte{eventID[i], eventID[i+1]}); err != nil { 34 | return -1 35 | } 36 | zeros += bits.LeadingZeros8(b[0]) 37 | break 38 | } 39 | return zeros 40 | } 41 | 42 | // Check reports whether the event ID demonstrates a sufficient proof of work difficulty. 43 | // Note that Check performs no validation other than counting leading zero bits 44 | // in an event ID. It is up to the callers to verify the event with other methods, 45 | // such as [nostr.Event.CheckSignature]. 46 | func Check(eventID string, minDifficulty int) error { 47 | if Difficulty(eventID) < minDifficulty { 48 | return ErrDifficultyTooLow 49 | } 50 | return nil 51 | } 52 | 53 | // Generate performs proof of work on the specified event until either the target 54 | // difficulty is reached or the function runs for longer than the timeout. 55 | // The latter case results in ErrGenerateTimeout. 56 | // 57 | // Upon success, the returned event always contains a "nonce" tag with the target difficulty 58 | // commitment, and an updated event.CreatedAt. 59 | func Generate(event *nostr.Event, targetDifficulty int, timeout time.Duration) (*nostr.Event, error) { 60 | tag := nostr.Tag{"nonce", "", strconv.Itoa(targetDifficulty)} 61 | event.Tags = append(event.Tags, tag) 62 | var nonce uint64 63 | start := time.Now() 64 | for { 65 | nonce++ 66 | tag[1] = strconv.FormatUint(nonce, 10) 67 | event.CreatedAt = nostr.Now() 68 | if Difficulty(event.GetID()) >= targetDifficulty { 69 | return event, nil 70 | } 71 | // benchmarks show one iteration is approx 3000ns on i7-8565U @ 1.8GHz. 72 | // so, check every 3ms; arbitrary 73 | if nonce%1000 == 0 && time.Since(start) > timeout { 74 | return nil, ErrGenerateTimeout 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/nostr/filter.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "golang.org/x/exp/slices" 7 | ) 8 | 9 | type Filters []Filter 10 | 11 | type Filter struct { 12 | IDs []string `json:"ids,omitempty"` 13 | Kinds []int `json:"kinds,omitempty"` 14 | Authors []string `json:"authors,omitempty"` 15 | Tags TagMap `json:"-,omitempty"` 16 | Since *Timestamp `json:"since,omitempty"` 17 | Until *Timestamp `json:"until,omitempty"` 18 | Limit int `json:"limit,omitempty"` 19 | Search string `json:"search,omitempty"` 20 | } 21 | 22 | type TagMap map[string][]string 23 | 24 | func (eff Filters) String() string { 25 | j, _ := json.Marshal(eff) 26 | return string(j) 27 | } 28 | 29 | func (eff Filters) Match(event *Event) bool { 30 | for _, filter := range eff { 31 | if filter.Matches(event) { 32 | return true 33 | } 34 | } 35 | return false 36 | } 37 | 38 | func (ef Filter) String() string { 39 | j, _ := json.Marshal(ef) 40 | return string(j) 41 | } 42 | 43 | func (ef Filter) Matches(event *Event) bool { 44 | if event == nil { 45 | return false 46 | } 47 | 48 | if ef.IDs != nil && !containsPrefixOf(ef.IDs, event.ID) { 49 | return false 50 | } 51 | 52 | if ef.Kinds != nil && !slices.Contains(ef.Kinds, event.Kind) { 53 | return false 54 | } 55 | 56 | if ef.Authors != nil && !containsPrefixOf(ef.Authors, event.PubKey) { 57 | return false 58 | } 59 | 60 | for f, v := range ef.Tags { 61 | if v != nil && !event.Tags.ContainsAny(f, v) { 62 | return false 63 | } 64 | } 65 | 66 | if ef.Since != nil && event.CreatedAt < *ef.Since { 67 | return false 68 | } 69 | 70 | if ef.Until != nil && event.CreatedAt > *ef.Until { 71 | return false 72 | } 73 | 74 | return true 75 | } 76 | 77 | func FilterEqual(a Filter, b Filter) bool { 78 | if !similar(a.Kinds, b.Kinds) { 79 | return false 80 | } 81 | 82 | if !similar(a.IDs, b.IDs) { 83 | return false 84 | } 85 | 86 | if !similar(a.Authors, b.Authors) { 87 | return false 88 | } 89 | 90 | if len(a.Tags) != len(b.Tags) { 91 | return false 92 | } 93 | 94 | for f, av := range a.Tags { 95 | if bv, ok := b.Tags[f]; !ok { 96 | return false 97 | } else { 98 | if !similar(av, bv) { 99 | return false 100 | } 101 | } 102 | } 103 | 104 | if a.Since != b.Since { 105 | return false 106 | } 107 | 108 | if a.Until != b.Until { 109 | return false 110 | } 111 | 112 | if a.Search != b.Search { 113 | return false 114 | } 115 | 116 | return true 117 | } 118 | -------------------------------------------------------------------------------- /pkg/nostr/subscription.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "sync" 8 | ) 9 | 10 | type Subscription struct { 11 | label string 12 | counter int 13 | conn *Connection 14 | mutex sync.Mutex 15 | 16 | Relay *Relay 17 | Filters Filters 18 | Events chan *Event 19 | EndOfStoredEvents chan struct{} 20 | Context context.Context 21 | cancel context.CancelFunc 22 | 23 | stopped bool 24 | emitEose sync.Once 25 | } 26 | 27 | type EventMessage struct { 28 | Event Event 29 | Relay string 30 | } 31 | 32 | // SetLabel puts a label on the subscription that is prepended to the id that is sent to relays, 33 | // 34 | // it's only useful for debugging and sanity purposes. 35 | func (sub *Subscription) SetLabel(label string) { 36 | sub.label = label 37 | } 38 | 39 | // GetID return the Nostr subscription ID as given to the relay, it will be a sequential number, stringified. 40 | func (sub *Subscription) GetID() string { 41 | return sub.label + ":" + strconv.Itoa(sub.counter) 42 | } 43 | 44 | // Unsub closes the subscription, sending "CLOSE" to relay as in NIP-01. 45 | // Unsub() also closes the channel sub.Events. 46 | func (sub *Subscription) Unsub() { 47 | sub.mutex.Lock() 48 | defer sub.mutex.Unlock() 49 | 50 | closeMsg := CloseEnvelope(sub.GetID()) 51 | closeb, _ := (&closeMsg).MarshalJSON() 52 | debugLog("{%s} sending %v", sub.Relay.URL, closeb) 53 | sub.conn.WriteMessage(closeb) 54 | 55 | if !sub.stopped && sub.Events != nil { 56 | close(sub.Events) 57 | } 58 | sub.stopped = true 59 | } 60 | 61 | // Sub sets sub.Filters and then calls sub.Fire(ctx). 62 | func (sub *Subscription) Sub(ctx context.Context, filters Filters) { 63 | sub.Filters = filters 64 | sub.Fire() 65 | } 66 | 67 | // Fire sends the "REQ" command to the relay. 68 | func (sub *Subscription) Fire() error { 69 | sub.Relay.subscriptions.Store(sub.GetID(), sub) 70 | 71 | reqb, _ := ReqEnvelope{sub.GetID(), sub.Filters}.MarshalJSON() 72 | debugLog("{%s} sending %v", sub.Relay.URL, reqb) 73 | err := sub.conn.WriteMessage(reqb) 74 | if err != nil { 75 | sub.cancel() 76 | return fmt.Errorf("failed to write: %w", err) 77 | } 78 | 79 | // the subscription ends once the context is canceled 80 | go func() { 81 | <-sub.Context.Done() 82 | sub.Unsub() 83 | }() 84 | 85 | // or when the relay connection is closed 86 | go func() { 87 | <-sub.Relay.connectionContext.Done() 88 | 89 | // cancel the context -- this will cause the other context cancelation cause above to be called 90 | sub.cancel() 91 | }() 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /cmd/agent/internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "uw/uboot" 11 | "uw/ulog" 12 | 13 | "nrat/pkg/nostr" 14 | "nrat/utils" 15 | 16 | "nrat/model" 17 | ) 18 | 19 | var ( 20 | //go:embed empty.bin 21 | DATA []byte 22 | sharp, percent byte = '#', '%' 23 | ) 24 | 25 | func fixWarning() { 26 | ulog.Warn("如果看到这个警告,请先使用控制端的 fix 命令修补 agent 以填充配置文件") 27 | ulog.Warn("Если вы видите это предупреждение, сначала используйте команду fix контрольного терминала для восстановления агента, чтобы заполнить файл конфигурации") 28 | ulog.Warn("If you see this warning, please use the fix command of the control terminal to repair the agent to fill in the configuration file") 29 | ulog.Warn("警告を見たら、まず制御端の fix コマンドを使って agent を修復して設定ファイルを埋めます") 30 | ulog.Warn("경고를 보면 먼저 제어 단의 fix 명령을 사용하여 agent를 수리하여 설정 파일을 채웁니다") 31 | os.Exit(1) 32 | } 33 | 34 | func StorageUint(c *uboot.Context) (e error) { 35 | s := &Storage{ 36 | storageData: &model.AgentStorageData{}, 37 | } 38 | noneMagic, startMagic, endMagic := byte('\x00'), []byte{percent, percent, percent, sharp}, 39 | []byte{sharp, percent, percent, percent} 40 | 41 | b, e := utils.ReadEmbedData(DATA, noneMagic, startMagic, endMagic) 42 | if e != nil { 43 | return fmt.Errorf("read embed data error: %s", e) 44 | } 45 | 46 | b = bytes.TrimSpace(b) 47 | if len(b) < 1 || !bytes.HasPrefix(b, []byte("{")) || 48 | !bytes.HasSuffix(b, []byte("}")) { 49 | fixWarning() 50 | return errors.New("invalid storage file") 51 | } 52 | 53 | if e := json.Unmarshal(b, s.storageData); e != nil { 54 | fixWarning() 55 | return fmt.Errorf("unmarshal storage file failed: %s", e) 56 | } 57 | 58 | if s.storageData.PublicKey, e = nostr. 59 | GetPublicKey(s.storageData.PrivateKey); e != nil { 60 | fixWarning() 61 | return fmt.Errorf("get public key failed: %s", e) 62 | } 63 | 64 | ulog.Info("public key: %s", utils.CutMore(s.storageData.PublicKey, 10)) 65 | 66 | if len(os.Args) > 1 && os.Args[1] == "key" { 67 | fmt.Printf("public key: %s\nprivate key: %s\n", 68 | s.storageData.PublicKey, utils.CutMore(s.storageData.PrivateKey, 10)) 69 | } 70 | 71 | c.Set("storage", s) 72 | return nil 73 | } 74 | 75 | type Storage struct { 76 | storageData *model.AgentStorageData 77 | } 78 | 79 | func (s *Storage) Storage() *model.AgentStorageData { 80 | return s.storageData 81 | } 82 | 83 | func (s *Storage) Unostr() *model.UnostrStorageData { 84 | return s.storageData.UnostrStorageData 85 | } 86 | 87 | func (s *Storage) Write() error { 88 | return nil 89 | } 90 | 91 | func (s *Storage) Read() error { 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/nostr/sdk/references.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | 8 | "nrat/pkg/nostr" 9 | 10 | "github.com/nbd-wtf/go-nostr/nip19" 11 | ) 12 | 13 | type Reference struct { 14 | Text string 15 | Start int 16 | End int 17 | Profile *nostr.ProfilePointer 18 | Event *nostr.EventPointer 19 | Entity *nostr.EntityPointer 20 | } 21 | 22 | var mentionRegex = regexp.MustCompile(`\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]`) 23 | 24 | // ParseReferences parses both NIP-08 and NIP-27 references in a single unifying interface. 25 | func ParseReferences(evt *nostr.Event) []*Reference { 26 | var references []*Reference 27 | content := evt.Content 28 | 29 | for _, ref := range mentionRegex.FindAllStringSubmatchIndex(evt.Content, -1) { 30 | reference := &Reference{ 31 | Text: content[ref[0]:ref[1]], 32 | Start: ref[0], 33 | End: ref[1], 34 | } 35 | 36 | if ref[6] == -1 { 37 | // didn't find a NIP-10 #[0] reference, so it's it's a NIP-27 mention 38 | nip19code := content[ref[2]:ref[3]] 39 | 40 | if prefix, data, err := nip19.Decode(nip19code); err == nil { 41 | switch prefix { 42 | case "npub": 43 | reference.Profile = &nostr.ProfilePointer{ 44 | PublicKey: data.(string), Relays: []string{}, 45 | } 46 | case "nprofile": 47 | pp := data.(nostr.ProfilePointer) 48 | reference.Profile = &pp 49 | case "note": 50 | reference.Event = &nostr.EventPointer{ID: data.(string), Relays: []string{}} 51 | case "nevent": 52 | evp := data.(nostr.EventPointer) 53 | reference.Event = &evp 54 | case "naddr": 55 | addr := data.(nostr.EntityPointer) 56 | reference.Entity = &addr 57 | } 58 | } 59 | } else { 60 | // it's a NIP-10 mention. 61 | // parse the number, get data from event tags. 62 | n := content[ref[6]:ref[7]] 63 | idx, err := strconv.Atoi(n) 64 | if err != nil || len(evt.Tags) <= idx { 65 | continue 66 | } 67 | if tag := evt.Tags[idx]; len(tag) >= 2 { 68 | switch tag[0] { 69 | case "p": 70 | relays := make([]string, 0, 1) 71 | if len(tag) > 2 && tag[2] != "" { 72 | relays = append(relays, tag[2]) 73 | } 74 | reference.Profile = &nostr.ProfilePointer{ 75 | PublicKey: tag[1], 76 | Relays: relays, 77 | } 78 | case "e": 79 | relays := make([]string, 0, 1) 80 | if len(tag) > 2 && tag[2] != "" { 81 | relays = append(relays, tag[2]) 82 | } 83 | reference.Event = &nostr.EventPointer{ 84 | ID: tag[1], 85 | Relays: relays, 86 | } 87 | case "a": 88 | if parts := strings.Split(tag[1], ":"); len(parts) == 3 { 89 | kind, _ := strconv.Atoi(parts[0]) 90 | relays := make([]string, 0, 1) 91 | if len(tag) > 2 && tag[2] != "" { 92 | relays = append(relays, tag[2]) 93 | } 94 | reference.Entity = &nostr.EntityPointer{ 95 | Identifier: parts[2], 96 | PublicKey: parts[1], 97 | Kind: kind, 98 | Relays: relays, 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | references = append(references, reference) 106 | } 107 | 108 | return references 109 | } 110 | -------------------------------------------------------------------------------- /pkg/nostr/pool.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | syncmap "github.com/SaveTheRbtz/generic-sync-map-go" 9 | ) 10 | 11 | type SimplePool struct { 12 | Relays map[string]*Relay 13 | Context context.Context 14 | 15 | mutex sync.Mutex 16 | cancel context.CancelFunc 17 | } 18 | 19 | func NewSimplePool(ctx context.Context) *SimplePool { 20 | ctx, cancel := context.WithCancel(ctx) 21 | 22 | return &SimplePool{ 23 | Relays: make(map[string]*Relay), 24 | 25 | Context: ctx, 26 | cancel: cancel, 27 | } 28 | } 29 | 30 | func (pool *SimplePool) EnsureRelay(url string) (*Relay, error) { 31 | nm := NormalizeURL(url) 32 | 33 | pool.mutex.Lock() 34 | defer pool.mutex.Unlock() 35 | 36 | relay, ok := pool.Relays[nm] 37 | if ok && relay.connectionContext.Err() == nil { 38 | // already connected, unlock and return 39 | return relay, nil 40 | } else { 41 | var err error 42 | // we use this ctx here so when the pool dies everything dies 43 | relay, err = RelayConnect(pool.Context, nm) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to connect: %w", err) 46 | } 47 | 48 | pool.Relays[nm] = relay 49 | return relay, nil 50 | } 51 | } 52 | 53 | // SubMany opens a subscription with the given filters to multiple relays 54 | // the subscriptions only end when the context is canceled 55 | func (pool *SimplePool) SubMany( 56 | ctx context.Context, 57 | urls []string, 58 | filters Filters, 59 | ) chan *Event { 60 | uniqueEvents := make(chan *Event) 61 | seenAlready := syncmap.MapOf[string, struct{}]{} 62 | 63 | for _, url := range urls { 64 | go func(nm string) { 65 | relay, err := pool.EnsureRelay(nm) 66 | if err != nil { 67 | return 68 | } 69 | 70 | sub, _ := relay.Subscribe(ctx, filters) 71 | if sub == nil { 72 | return 73 | } 74 | 75 | for evt := range sub.Events { 76 | // dispatch unique events to client 77 | if _, ok := seenAlready.LoadOrStore(evt.ID, struct{}{}); !ok { 78 | uniqueEvents <- evt 79 | } 80 | } 81 | }(NormalizeURL(url)) 82 | } 83 | 84 | return uniqueEvents 85 | } 86 | 87 | // SubManyEose is like SubMany, but it stops subscriptions and closes the channel when gets a EOSE 88 | func (pool *SimplePool) SubManyEose( 89 | ctx context.Context, 90 | urls []string, 91 | filters Filters, 92 | ) chan *Event { 93 | ctx, cancel := context.WithCancel(ctx) 94 | 95 | uniqueEvents := make(chan *Event) 96 | seenAlready := syncmap.MapOf[string, struct{}]{} 97 | wg := sync.WaitGroup{} 98 | wg.Add(len(urls)) 99 | 100 | go func() { 101 | // this will happen when all subscriptions get an eose (or when they die) 102 | wg.Wait() 103 | cancel() 104 | close(uniqueEvents) 105 | }() 106 | 107 | for _, url := range urls { 108 | go func(nm string) { 109 | relay, err := pool.EnsureRelay(nm) 110 | if err != nil { 111 | return 112 | } 113 | 114 | sub, _ := relay.Subscribe(ctx, filters) 115 | if sub == nil { 116 | wg.Done() 117 | return 118 | } 119 | 120 | defer wg.Done() 121 | 122 | for { 123 | select { 124 | case <-sub.EndOfStoredEvents: 125 | return 126 | case evt, more := <-sub.Events: 127 | if !more { 128 | return 129 | } 130 | 131 | // dispatch unique events to client 132 | if _, ok := seenAlready.LoadOrStore(evt.ID, struct{}{}); !ok { 133 | uniqueEvents <- evt 134 | } 135 | } 136 | } 137 | }(NormalizeURL(url)) 138 | } 139 | 140 | return uniqueEvents 141 | } 142 | -------------------------------------------------------------------------------- /pkg/nostr/sdk/references_test.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "nrat/pkg/nostr" 8 | ) 9 | 10 | func TestParseReferences(t *testing.T) { 11 | evt := nostr.Event{ 12 | Tags: nostr.Tags{ 13 | {"p", "c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8", "wss://nostr.com"}, 14 | {"e", "a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33", "wss://other.com", "reply"}, 15 | {"e", "31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8", ""}, 16 | }, 17 | Content: "hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]", 18 | } 19 | 20 | expected := []Reference{ 21 | { 22 | Text: "#[0]", 23 | Start: 6, 24 | End: 10, 25 | Profile: &nostr.ProfilePointer{ 26 | PublicKey: "c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8", 27 | Relays: []string{"wss://nostr.com"}, 28 | }, 29 | }, 30 | { 31 | Text: "#[2]", 32 | Start: 26, 33 | End: 30, 34 | Event: &nostr.EventPointer{ 35 | ID: "31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8", 36 | Relays: []string{}, 37 | }, 38 | }, 39 | { 40 | Text: "nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg", 41 | Start: 47, 42 | End: 123, 43 | Profile: &nostr.ProfilePointer{ 44 | PublicKey: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393", 45 | Relays: []string{}, 46 | }, 47 | }, 48 | { 49 | Text: "nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4", 50 | Start: 127, 51 | End: 201, 52 | Event: &nostr.EventPointer{ 53 | ID: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393", 54 | Relays: []string{}, 55 | Author: "", 56 | }, 57 | }, 58 | } 59 | 60 | got := ParseReferences(&evt) 61 | 62 | if len(got) != len(expected) { 63 | t.Errorf("got %d references, expected %d", len(got), len(expected)) 64 | } 65 | 66 | for i, g := range got { 67 | e := expected[i] 68 | if g.Text != e.Text { 69 | t.Errorf("%d: got text %s, expected %s", i, g.Text, e.Text) 70 | } 71 | 72 | if g.Start != e.Start { 73 | t.Errorf("%d: got start %d, expected %d", i, g.Start, e.Start) 74 | } 75 | 76 | if g.End != e.End { 77 | t.Errorf("%d: got end %d, expected %d", i, g.End, e.End) 78 | } 79 | 80 | if (g.Entity == nil && e.Entity != nil) || 81 | (g.Event == nil && e.Event != nil) || 82 | (g.Profile == nil && e.Profile != nil) { 83 | t.Errorf("%d: got some unexpected nil", i) 84 | } 85 | 86 | if g.Profile != nil && (g.Profile.PublicKey != e.Profile.PublicKey || 87 | len(g.Profile.Relays) != len(e.Profile.Relays) || 88 | (len(g.Profile.Relays) > 0 && g.Profile.Relays[0] != e.Profile.Relays[0])) { 89 | t.Errorf("%d: profile value is wrong", i) 90 | } 91 | 92 | if g.Event != nil && (g.Event.ID != e.Event.ID || 93 | g.Event.Author != e.Event.Author || 94 | len(g.Event.Relays) != len(e.Event.Relays) || 95 | (len(g.Event.Relays) > 0 && g.Event.Relays[0] != e.Event.Relays[0])) { 96 | fmt.Println(g.Event.ID, g.Event.Relays, len(g.Event.Relays), g.Event.Relays[0] == "") 97 | fmt.Println(e.Event.Relays, len(e.Event.Relays)) 98 | t.Errorf("%d: event value is wrong", i) 99 | } 100 | 101 | if g.Entity != nil && (g.Entity.PublicKey != e.Entity.PublicKey || 102 | g.Entity.Identifier != e.Entity.Identifier || 103 | g.Entity.Kind != e.Entity.Kind || 104 | len(g.Entity.Relays) != len(e.Entity.Relays)) { 105 | t.Errorf("%d: entity value is wrong", i) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /pkg/ishell/command.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "text/tabwriter" 9 | ) 10 | 11 | // Cmd is a shell command handler. 12 | type Cmd struct { 13 | // Command name. 14 | Name string 15 | // Command name aliases. 16 | Aliases []string 17 | // Function to execute for the command. 18 | Func func(c *Context) 19 | // One liner help message for the command. 20 | Help string 21 | // More descriptive help message for the command. 22 | LongHelp string 23 | 24 | // Completer is custom autocomplete for command. 25 | // It takes in command arguments and returns 26 | // autocomplete options. 27 | // By default all commands get autocomplete of 28 | // subcommands. 29 | // A non-nil Completer overrides the default behaviour. 30 | Completer func(args []string) []string 31 | 32 | // CompleterWithPrefix is custom autocomplete like 33 | // for Completer, but also provides the prefix 34 | // already so far to the completion function 35 | // If both Completer and CompleterWithPrefix are given, 36 | // CompleterWithPrefix takes precedence 37 | CompleterWithPrefix func(prefix string, args []string) []string 38 | 39 | // subcommands. 40 | children map[string]*Cmd 41 | } 42 | 43 | // AddCmd adds cmd as a subcommand. 44 | func (c *Cmd) AddCmd(cmd *Cmd) { 45 | if c.children == nil { 46 | c.children = make(map[string]*Cmd) 47 | } 48 | c.children[cmd.Name] = cmd 49 | } 50 | 51 | // DeleteCmd deletes cmd from subcommands. 52 | func (c *Cmd) DeleteCmd(name string) { 53 | delete(c.children, name) 54 | } 55 | 56 | // Children returns the subcommands of c. 57 | func (c *Cmd) Children() []*Cmd { 58 | var cmds []*Cmd 59 | for _, cmd := range c.children { 60 | cmds = append(cmds, cmd) 61 | } 62 | sort.Sort(cmdSorter(cmds)) 63 | return cmds 64 | } 65 | 66 | func (c *Cmd) hasSubcommand() bool { 67 | if len(c.children) > 1 { 68 | return true 69 | } 70 | if _, ok := c.children["help"]; !ok { 71 | return len(c.children) > 0 72 | } 73 | return false 74 | } 75 | 76 | // HelpText returns the computed help of the command and its subcommands. 77 | func (c Cmd) HelpText() string { 78 | var b bytes.Buffer 79 | p := func(s ...interface{}) { 80 | fmt.Fprintln(&b) 81 | if len(s) > 0 { 82 | fmt.Fprintln(&b, s...) 83 | } 84 | } 85 | if c.LongHelp != "" { 86 | p(c.LongHelp) 87 | } else if c.Help != "" { 88 | p(c.Help) 89 | } else if c.Name != "" { 90 | p(c.Name, "has no help") 91 | } 92 | if c.hasSubcommand() { 93 | p("Commands:") 94 | w := tabwriter.NewWriter(&b, 0, 4, 2, ' ', 0) 95 | for _, child := range c.Children() { 96 | if child.Aliases != nil { 97 | fmt.Fprintf(w, "\t%s, %s\t\t\t%s\n", 98 | child.Name, strings.Join(child.Aliases, ", "), child.Help) 99 | continue 100 | } 101 | 102 | fmt.Fprintf(w, "\t%s\t\t\t%s\n", child.Name, child.Help) 103 | } 104 | w.Flush() 105 | p() 106 | } 107 | return b.String() 108 | } 109 | 110 | // findChildCmd returns the subcommand with matching name or alias. 111 | func (c *Cmd) findChildCmd(name string) *Cmd { 112 | // find perfect matches first 113 | if cmd, ok := c.children[name]; ok { 114 | return cmd 115 | } 116 | 117 | // find alias matching the name 118 | for _, cmd := range c.children { 119 | for _, alias := range cmd.Aliases { 120 | if alias == name { 121 | return cmd 122 | } 123 | } 124 | } 125 | 126 | return nil 127 | } 128 | 129 | // FindCmd finds the matching Cmd for args. 130 | // It returns the Cmd and the remaining args. 131 | func (c Cmd) FindCmd(args []string) (*Cmd, []string) { 132 | var cmd *Cmd 133 | for i, arg := range args { 134 | if cmd1 := c.findChildCmd(arg); cmd1 != nil { 135 | cmd = cmd1 136 | c = *cmd 137 | continue 138 | } 139 | return cmd, args[i:] 140 | } 141 | return cmd, nil 142 | } 143 | 144 | type cmdSorter []*Cmd 145 | 146 | func (c cmdSorter) Len() int { return len(c) } 147 | func (c cmdSorter) Less(i, j int) bool { return c[i].Name < c[j].Name } 148 | func (c cmdSorter) Swap(i, j int) { c[i], c[j] = c[j], c[i] } 149 | -------------------------------------------------------------------------------- /pkg/nostr/filter_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "golang.org/x/exp/slices" 8 | ) 9 | 10 | func TestFilterUnmarshal(t *testing.T) { 11 | raw := `{"ids": ["abc"],"#e":["zzz"],"#something":["nothing","bab"],"since":1644254609,"search":"test"}` 12 | var f Filter 13 | err := json.Unmarshal([]byte(raw), &f) 14 | if err != nil { 15 | t.Errorf("failed to parse filter json: %v", err) 16 | } 17 | 18 | if f.Since == nil || f.Since.Time().UTC().Format("2006-01-02") != "2022-02-07" || 19 | f.Until != nil || 20 | f.Tags == nil || len(f.Tags) != 2 || !slices.Contains(f.Tags["something"], "bab") || 21 | f.Search != "test" { 22 | t.Error("failed to parse filter correctly") 23 | } 24 | } 25 | 26 | func TestFilterMarshal(t *testing.T) { 27 | until := Timestamp(12345678) 28 | filterj, err := json.Marshal(Filter{ 29 | Kinds: []int{1, 2, 4}, 30 | Tags: TagMap{"fruit": {"banana", "mango"}}, 31 | Until: &until, 32 | }) 33 | if err != nil { 34 | t.Errorf("failed to marshal filter json: %v", err) 35 | } 36 | 37 | expected := `{"kinds":[1,2,4],"until":12345678,"#fruit":["banana","mango"]}` 38 | if string(filterj) != expected { 39 | t.Errorf("filter json was wrong: %s != %s", string(filterj), expected) 40 | } 41 | } 42 | 43 | func TestFilterMatching(t *testing.T) { 44 | if (Filter{Kinds: []int{4, 5}}).Matches(&Event{Kind: 6}) { 45 | t.Error("matched event that shouldn't have matched") 46 | } 47 | 48 | if !(Filter{Kinds: []int{4, 5}}).Matches(&Event{Kind: 4}) { 49 | t.Error("failed to match event by kind") 50 | } 51 | 52 | if !(Filter{ 53 | Kinds: []int{4, 5}, 54 | Tags: TagMap{ 55 | "p": {"ooo"}, 56 | }, 57 | IDs: []string{"prefix"}, 58 | }).Matches(&Event{ 59 | Kind: 4, 60 | Tags: Tags{{"p", "ooo", ",x,x,"}, {"m", "yywyw", "xxx"}}, 61 | ID: "prefix123", 62 | }) { 63 | t.Error("failed to match event by kind+tags+id prefix") 64 | } 65 | } 66 | 67 | func TestFilterMatchingLive(t *testing.T) { 68 | var filter Filter 69 | var event Event 70 | 71 | json.Unmarshal([]byte(`{"kinds":[1],"authors":["a8171781fd9e90ede3ea44ddca5d3abf828fe8eedeb0f3abb0dd3e563562e1fc","1d80e5588de010d137a67c42b03717595f5f510e73e42cfc48f31bae91844d59","ed4ca520e9929dfe9efdadf4011b53d30afd0678a09aa026927e60e7a45d9244"],"since":1677033299}`), &filter) 72 | json.Unmarshal([]byte(`{"id":"5a127c9c931f392f6afc7fdb74e8be01c34035314735a6b97d2cf360d13cfb94","pubkey":"1d80e5588de010d137a67c42b03717595f5f510e73e42cfc48f31bae91844d59","created_at":1677033299,"kind":1,"tags":[["t","japan"]],"content":"If you like my art,I'd appreciate a coin or two!!\nZap is welcome!! Thanks.\n\n\n#japan #bitcoin #art #bananaart\nhttps://void.cat/d/CgM1bzDgHUCtiNNwfX9ajY.webp","sig":"828497508487ca1e374f6b4f2bba7487bc09fccd5cc0d1baa82846a944f8c5766918abf5878a580f1e6615de91f5b57a32e34c42ee2747c983aaf47dbf2a0255"}`), &event) 73 | 74 | if !filter.Matches(&event) { 75 | t.Error("live filter should match") 76 | } 77 | } 78 | 79 | func TestFilterEquality(t *testing.T) { 80 | if !FilterEqual( 81 | Filter{Kinds: []int{4, 5}}, 82 | Filter{Kinds: []int{4, 5}}, 83 | ) { 84 | t.Error("kinds filters should be equal") 85 | } 86 | 87 | if !FilterEqual( 88 | Filter{Kinds: []int{4, 5}, Tags: TagMap{"letter": {"a", "b"}}}, 89 | Filter{Kinds: []int{4, 5}, Tags: TagMap{"letter": {"b", "a"}}}, 90 | ) { 91 | t.Error("kind+tags filters should be equal") 92 | } 93 | 94 | tm := Now() 95 | if !FilterEqual( 96 | Filter{ 97 | Kinds: []int{4, 5}, 98 | Tags: TagMap{"letter": {"a", "b"}, "fruit": {"banana"}}, 99 | Since: &tm, 100 | IDs: []string{"aaaa", "bbbb"}, 101 | }, 102 | Filter{ 103 | Kinds: []int{5, 4}, 104 | Tags: TagMap{"letter": {"a", "b"}, "fruit": {"banana"}}, 105 | Since: &tm, 106 | IDs: []string{"aaaa", "bbbb"}, 107 | }, 108 | ) { 109 | t.Error("kind+2tags+since+ids filters should be equal") 110 | } 111 | 112 | if FilterEqual( 113 | Filter{Kinds: []int{1, 4, 5}}, 114 | Filter{Kinds: []int{4, 5, 6}}, 115 | ) { 116 | t.Error("kinds filters shouldn't be equal") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pkg/nostr/nip04/nip04.go: -------------------------------------------------------------------------------- 1 | package nip04 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "fmt" 11 | "strings" 12 | 13 | "github.com/btcsuite/btcd/btcec/v2" 14 | ) 15 | 16 | // ComputeSharedSecret returns a shared secret key used to encrypt messages. 17 | // The private and public keys should be hex encoded. 18 | // Uses the Diffie-Hellman key exchange (ECDH) (RFC 4753). 19 | func ComputeSharedSecret(pub string, sk string) (sharedSecret []byte, err error) { 20 | privKeyBytes, err := hex.DecodeString(sk) 21 | if err != nil { 22 | return nil, fmt.Errorf("error decoding sender private key: %w", err) 23 | } 24 | privKey, _ := btcec.PrivKeyFromBytes(privKeyBytes) 25 | 26 | // adding 02 to signal that this is a compressed public key (33 bytes) 27 | pubKeyBytes, err := hex.DecodeString("02" + pub) 28 | if err != nil { 29 | return nil, fmt.Errorf("error decoding hex string of receiver public key '%s': %w", "02"+pub, err) 30 | } 31 | pubKey, err := btcec.ParsePubKey(pubKeyBytes) 32 | if err != nil { 33 | return nil, fmt.Errorf("error parsing receiver public key '%s': %w", "02"+pub, err) 34 | } 35 | 36 | return btcec.GenerateSharedSecret(privKey, pubKey), nil 37 | } 38 | 39 | // Encrypt encrypts message with key using aes-256-cbc. 40 | // key should be the shared secret generated by ComputeSharedSecret. 41 | // Returns: base64(encrypted_bytes) + "?iv=" + base64(initialization_vector). 42 | func Encrypt(message string, key []byte) (string, error) { 43 | // block size is 16 bytes 44 | iv := make([]byte, 16) 45 | // can probably use a less expensive lib since IV has to only be unique; not perfectly random; math/rand? 46 | _, err := rand.Read(iv) 47 | if err != nil { 48 | return "", fmt.Errorf("error creating initization vector: %w", err) 49 | } 50 | 51 | // automatically picks aes-256 based on key length (32 bytes) 52 | block, err := aes.NewCipher(key) 53 | if err != nil { 54 | return "", fmt.Errorf("error creating block cipher: %w", err) 55 | } 56 | mode := cipher.NewCBCEncrypter(block, iv) 57 | 58 | plaintext := []byte(message) 59 | 60 | // add padding 61 | base := len(plaintext) 62 | 63 | // this will be a number between 1 and 16 (including), never 0 64 | padding := block.BlockSize() - base%block.BlockSize() 65 | 66 | // encode the padding in all the padding bytes themselves 67 | padtext := bytes.Repeat([]byte{byte(padding)}, padding) 68 | 69 | paddedMsgBytes := append(plaintext, padtext...) 70 | 71 | ciphertext := make([]byte, len(paddedMsgBytes)) 72 | mode.CryptBlocks(ciphertext, paddedMsgBytes) 73 | 74 | return base64.StdEncoding.EncodeToString(ciphertext) + "?iv=" + base64.StdEncoding.EncodeToString(iv), nil 75 | } 76 | 77 | // Decrypt decrypts a content string using the shared secret key. 78 | // The inverse operation to message -> Encrypt(message, key). 79 | func Decrypt(content string, key []byte) (string, error) { 80 | parts := strings.Split(content, "?iv=") 81 | if len(parts) < 2 { 82 | return "", fmt.Errorf("error parsing encrypted message: no initilization vector") 83 | } 84 | 85 | ciphertext, err := base64.StdEncoding.DecodeString(parts[0]) 86 | if err != nil { 87 | return "", fmt.Errorf("error decoding ciphertext from base64: %w", err) 88 | } 89 | 90 | iv, err := base64.StdEncoding.DecodeString(parts[1]) 91 | if err != nil { 92 | return "", fmt.Errorf("error decoding iv from base64: %w", err) 93 | } 94 | 95 | block, err := aes.NewCipher(key) 96 | if err != nil { 97 | return "", fmt.Errorf("error creating block cipher: %w", err) 98 | } 99 | mode := cipher.NewCBCDecrypter(block, iv) 100 | plaintext := make([]byte, len(ciphertext)) 101 | mode.CryptBlocks(plaintext, ciphertext) 102 | 103 | // remove padding 104 | var ( 105 | message = string(plaintext) 106 | plaintextLen = len(plaintext) 107 | ) 108 | if plaintextLen > 0 { 109 | padding := int(plaintext[plaintextLen-1]) // the padding amount is encoded in the padding bytes themselves 110 | if padding > plaintextLen { 111 | return "", fmt.Errorf("invalid padding amount: %d", padding) 112 | } 113 | message = string(plaintext[0 : plaintextLen-padding]) 114 | } 115 | 116 | return message, nil 117 | } 118 | -------------------------------------------------------------------------------- /pkg/nostr/tags.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | 8 | "golang.org/x/exp/slices" 9 | ) 10 | 11 | type Tag []string 12 | 13 | // StartsWith checks if a tag contains a prefix. 14 | // for example, 15 | // ["p", "abcdef...", "wss://relay.com"] 16 | // would match against 17 | // ["p", "abcdef..."] 18 | // or even 19 | // ["p", "abcdef...", "wss://"] 20 | func (tag Tag) StartsWith(prefix []string) bool { 21 | prefixLen := len(prefix) 22 | 23 | if prefixLen > len(tag) { 24 | return false 25 | } 26 | // check initial elements for equality 27 | for i := 0; i < prefixLen-1; i++ { 28 | if prefix[i] != tag[i] { 29 | return false 30 | } 31 | } 32 | // check last element just for a prefix 33 | return strings.HasPrefix(tag[prefixLen-1], prefix[prefixLen-1]) 34 | } 35 | 36 | func (tag Tag) Key() string { 37 | if len(tag) > 0 { 38 | return tag[0] 39 | } 40 | return "" 41 | } 42 | 43 | func (tag Tag) Value() string { 44 | if len(tag) > 1 { 45 | return tag[1] 46 | } 47 | return "" 48 | } 49 | 50 | func (tag Tag) Relay() string { 51 | if (tag[0] == "e" || tag[0] == "p") && len(tag) > 2 { 52 | return NormalizeURL(tag[2]) 53 | } 54 | return "" 55 | } 56 | 57 | type Tags []Tag 58 | 59 | // GetFirst gets the first tag in tags that matches the prefix, see [Tag.StartsWith] 60 | func (tags Tags) GetFirst(tagPrefix []string) *Tag { 61 | for _, v := range tags { 62 | if v.StartsWith(tagPrefix) { 63 | return &v 64 | } 65 | } 66 | return nil 67 | } 68 | 69 | // GetLast gets the last tag in tags that matches the prefix, see [Tag.StartsWith] 70 | func (tags Tags) GetLast(tagPrefix []string) *Tag { 71 | for i := len(tags) - 1; i >= 0; i-- { 72 | v := tags[i] 73 | if v.StartsWith(tagPrefix) { 74 | return &v 75 | } 76 | } 77 | return nil 78 | } 79 | 80 | // GetAll gets all the tags that match the prefix, see [Tag.StartsWith] 81 | func (tags Tags) GetAll(tagPrefix []string) Tags { 82 | result := make(Tags, 0, len(tags)) 83 | for _, v := range tags { 84 | if v.StartsWith(tagPrefix) { 85 | result = append(result, v) 86 | } 87 | } 88 | return result 89 | } 90 | 91 | // FilterOut removes all tags that match the prefix, see [Tag.StartsWith] 92 | func (tags Tags) FilterOut(tagPrefix []string) Tags { 93 | filtered := make(Tags, 0, len(tags)) 94 | for _, v := range tags { 95 | if !v.StartsWith(tagPrefix) { 96 | filtered = append(filtered, v) 97 | } 98 | } 99 | return filtered 100 | } 101 | 102 | // AppendUnique appends a tag if it doesn't exist yet, otherwise does nothing. 103 | // the uniqueness comparison is done based only on the first 2 elements of the tag. 104 | func (tags Tags) AppendUnique(tag Tag) Tags { 105 | n := len(tag) 106 | if n > 2 { 107 | n = 2 108 | } 109 | 110 | if tags.GetFirst(tag[:n]) == nil { 111 | return append(tags, tag) 112 | } else { 113 | return tags 114 | } 115 | } 116 | 117 | func (t *Tags) Scan(src any) error { 118 | var jtags []byte = make([]byte, 0) 119 | 120 | switch v := src.(type) { 121 | case []byte: 122 | jtags = v 123 | case string: 124 | jtags = []byte(v) 125 | default: 126 | return errors.New("couldn't scan tags, it's not a json string") 127 | } 128 | 129 | json.Unmarshal(jtags, &t) 130 | return nil 131 | } 132 | 133 | func (tags Tags) ContainsAny(tagName string, values []string) bool { 134 | for _, tag := range tags { 135 | if len(tag) < 2 { 136 | continue 137 | } 138 | 139 | if tag[0] != tagName { 140 | continue 141 | } 142 | 143 | if slices.Contains(values, tag[1]) { 144 | return true 145 | } 146 | } 147 | 148 | return false 149 | } 150 | 151 | // Marshal Tag. Used for Serialization so string escaping should be as in RFC8259. 152 | func (tag Tag) marshalTo(dst []byte) []byte { 153 | dst = append(dst, '[') 154 | for i, s := range tag { 155 | if i > 0 { 156 | dst = append(dst, ',') 157 | } 158 | dst = escapeString(dst, s) 159 | } 160 | dst = append(dst, ']') 161 | return dst 162 | } 163 | 164 | // MarshalTo appends the JSON encoded byte of Tags as [][]string to dst. 165 | // String escaping is as described in RFC8259. 166 | func (tags Tags) marshalTo(dst []byte) []byte { 167 | dst = append(dst, '[') 168 | for i, tag := range tags { 169 | if i > 0 { 170 | dst = append(dst, ',') 171 | } 172 | dst = tag.marshalTo(dst) 173 | } 174 | dst = append(dst, ']') 175 | return dst 176 | } 177 | -------------------------------------------------------------------------------- /pkg/nostr/nip13/nip13_test.go: -------------------------------------------------------------------------------- 1 | package nip13 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "nrat/pkg/nostr" 11 | ) 12 | 13 | func TestCheck(t *testing.T) { 14 | const eventID = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" 15 | tests := []struct { 16 | minDifficulty int 17 | wantErr error 18 | }{ 19 | {-1, nil}, 20 | {0, nil}, 21 | {1, nil}, 22 | {35, nil}, 23 | {36, nil}, 24 | {37, ErrDifficultyTooLow}, 25 | {42, ErrDifficultyTooLow}, 26 | } 27 | for i, tc := range tests { 28 | if err := Check(eventID, tc.minDifficulty); err != tc.wantErr { 29 | t.Errorf("%d: Check(%q, %d) returned %v; want err: %v", i, eventID, tc.minDifficulty, err, tc.wantErr) 30 | } 31 | } 32 | } 33 | 34 | func TestGenerateShort(t *testing.T) { 35 | event := &nostr.Event{ 36 | Kind: 1, 37 | Content: "It's just me mining my own business", 38 | PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", 39 | } 40 | pow, err := Generate(event, 0, 3*time.Second) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | testNonceTag(t, pow, 0) 45 | } 46 | 47 | func TestGenerateLong(t *testing.T) { 48 | if testing.Short() { 49 | t.Skip("too consuming for short mode") 50 | } 51 | for _, difficulty := range []int{8, 16} { 52 | difficulty := difficulty 53 | t.Run(fmt.Sprintf("%dbits", difficulty), func(t *testing.T) { 54 | t.Parallel() 55 | event := &nostr.Event{ 56 | Kind: 1, 57 | Content: "It's just me mining my own business", 58 | PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", 59 | } 60 | pow, err := Generate(event, difficulty, time.Minute) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | if err := Check(pow.GetID(), difficulty); err != nil { 65 | t.Error(err) 66 | } 67 | testNonceTag(t, pow, difficulty) 68 | }) 69 | } 70 | } 71 | 72 | func testNonceTag(t *testing.T, event *nostr.Event, commitment int) { 73 | t.Helper() 74 | tagptr := event.Tags.GetFirst([]string{"nonce"}) 75 | if tagptr == nil { 76 | t.Fatal("no nonce tag") 77 | } 78 | tag := *tagptr 79 | if tag[0] != "nonce" { 80 | t.Errorf("tag[0] = %q; want 'nonce'", tag[0]) 81 | } 82 | if n, err := strconv.ParseInt(tag[1], 10, 64); err != nil || n < 1 { 83 | t.Errorf("tag[1] = %q; want an int greater than 0", tag[1]) 84 | } 85 | if n, err := strconv.Atoi(tag[2]); err != nil || n != commitment { 86 | t.Errorf("tag[2] = %q; want %d", tag[2], commitment) 87 | } 88 | } 89 | 90 | func TestGenerateTimeout(t *testing.T) { 91 | event := &nostr.Event{ 92 | Kind: 1, 93 | Content: "It's just me mining my own business", 94 | PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", 95 | } 96 | done := make(chan error) 97 | go func() { 98 | _, err := Generate(event, 256, time.Millisecond) 99 | done <- err 100 | }() 101 | select { 102 | case <-time.After(time.Second): 103 | t.Error("Generate took too long to timeout") 104 | case err := <-done: 105 | if !errors.Is(err, ErrGenerateTimeout) { 106 | t.Errorf("Generate returned %v; want ErrGenerateTimeout", err) 107 | } 108 | } 109 | } 110 | 111 | func BenchmarkCheck(b *testing.B) { 112 | for i := 0; i < b.N; i++ { 113 | Check("000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d", 36) 114 | } 115 | } 116 | 117 | func BenchmarkGenerateOneIteration(b *testing.B) { 118 | for i := 0; i < b.N; i++ { 119 | event := &nostr.Event{ 120 | Kind: 1, 121 | Content: "It's just me mining my own business", 122 | PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", 123 | } 124 | if _, err := Generate(event, 0, time.Minute); err != nil { 125 | b.Fatal(err) 126 | } 127 | } 128 | } 129 | 130 | func BenchmarkGenerate(b *testing.B) { 131 | if testing.Short() { 132 | b.Skip("too consuming for short mode") 133 | } 134 | for _, difficulty := range []int{8, 16, 24} { 135 | difficulty := difficulty 136 | b.Run(fmt.Sprintf("%dbits", difficulty), func(b *testing.B) { 137 | for i := 0; i < b.N; i++ { 138 | event := &nostr.Event{ 139 | Kind: 1, 140 | Content: "It's just me mining my own business", 141 | PubKey: "a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243", 142 | } 143 | if _, err := Generate(event, difficulty, time.Minute); err != nil { 144 | b.Fatal(err) 145 | } 146 | } 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /pkg/nostr/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | go-nostr 4 | ======== 5 | 6 | A set of useful things for [Nostr Protocol](https://github.com/nostr-protocol/nostr) implementations. 7 | 8 | GoDoc 9 |
10 | [![test every commit](https://github.com/nbd-wtf/go-nostr/actions/workflows/test.yml/badge.svg)](https://github.com/nbd-wtf/go-nostr/actions/workflows/test.yml) 11 | 12 | Install go-nostr: 13 | 14 | ```bash 15 | go get github.com/nbd-wtf/go-nostr 16 | ``` 17 | 18 | ### Generating a key 19 | 20 | ``` go 21 | package main 22 | 23 | import ( 24 | "fmt" 25 | 26 | "github.com/nbd-wtf/go-nostr" 27 | "github.com/nbd-wtf/go-nostr/nip19" 28 | ) 29 | 30 | func main() { 31 | sk := nostr.GeneratePrivateKey() 32 | pk, _ := nostr.GetPublicKey(sk) 33 | nsec, _ := nip19.EncodePrivateKey(sk) 34 | npub, _ := nip19.EncodePublicKey(pk) 35 | 36 | fmt.Println("sk:", sk) 37 | fmt.Println("pk:", pk) 38 | fmt.Println(nsec) 39 | fmt.Println(npub) 40 | } 41 | ``` 42 | 43 | ### Subscribing to a single relay 44 | 45 | ``` go 46 | ctx := context.Background() 47 | relay, err := nostr.RelayConnect(ctx, "wss://nostr.zebedee.cloud") 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | npub := "npub1422a7ws4yul24p0pf7cacn7cghqkutdnm35z075vy68ggqpqjcyswn8ekc" 53 | 54 | var filters nostr.Filters 55 | if _, v, err := nip19.Decode(npub); err == nil { 56 | pub := v.(string) 57 | filters = []nostr.Filter{{ 58 | Kinds: []int{nostr.KindTextNote}, 59 | Authors: []string{pub}, 60 | Limit: 1, 61 | }} 62 | } else { 63 | panic(err) 64 | } 65 | 66 | ctx, cancel := context.WithTimeout(ctx, 3*time.Second) 67 | defer cancel() 68 | 69 | sub, err := relay.Subscribe(ctx, filters) 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | for ev := range sub.Events { 75 | // handle returned event. 76 | // channel will stay open until the ctx is cancelled (in this case, context timeout) 77 | fmt.Println(ev.ID) 78 | } 79 | ``` 80 | 81 | ### Publishing to two relays 82 | 83 | ``` go 84 | sk := nostr.GeneratePrivateKey() 85 | pub, _ := nostr.GetPublicKey(sk) 86 | 87 | ev := nostr.Event{ 88 | PubKey: pub, 89 | CreatedAt: nostr.Now(), 90 | Kind: nostr.KindTextNote, 91 | Tags: nil, 92 | Content: "Hello World!", 93 | } 94 | 95 | // calling Sign sets the event ID field and the event Sig field 96 | ev.Sign(sk) 97 | 98 | // publish the event to two relays 99 | ctx := context.Background() 100 | for _, url := range []string{"wss://nostr.zebedee.cloud", "wss://nostr-pub.wellorder.net"} { 101 | relay, err := nostr.RelayConnect(ctx, url) 102 | if err != nil { 103 | fmt.Println(err) 104 | continue 105 | } 106 | _, err = relay.Publish(ctx, ev) 107 | if err != nil { 108 | fmt.Println(err) 109 | continue 110 | } 111 | 112 | fmt.Printf("published to %s\n", url) 113 | } 114 | ``` 115 | 116 | ### Authenticating with NIP-42 117 | 118 | For this section, the user needs access to a relay implementing NIP-42. 119 | E.g., https://github.com/fiatjaf/relayer with a relay implementing the relayer.Auther interface. 120 | 121 | ``` go 122 | func main() { 123 | url := "ws://localhost:7447" 124 | 125 | // Once the connection is initiated the server will send "AUTH" with the challenge string. 126 | relay, err := nostr.RelayConnect(context.Background(), url) 127 | if err != nil { 128 | panic(err) 129 | } 130 | 131 | // Initialize test user. 132 | sk := nostr.GeneratePrivateKey() 133 | pub, _ := nostr.GetPublicKey(sk) 134 | npub, _ := nip19.EncodePublicKey(pub) 135 | 136 | // Relay.Challenges channel will receive the "AUTH" command. 137 | challenge := <-relay.Challenges 138 | 139 | // Create the auth event to send back. 140 | // The user will be authenticated as pub. 141 | event := nip42.CreateUnsignedAuthEvent(challenge, pub, url) 142 | event.Sign(sk) 143 | 144 | // Set-up context with 3 second time out. 145 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 146 | defer cancel() 147 | 148 | // Send the event by calling relay.Auth. 149 | // Returned status is either success, fail, or sent (if no reply given in the 3 second timeout). 150 | auth_status, err := relay.Auth(ctx, event) 151 | 152 | fmt.Printf("authenticated as %s: %s\n", npub, auth_status) 153 | } 154 | ``` 155 | 156 | ### Example script 157 | 158 | ``` 159 | go run example/example.go 160 | ``` 161 | -------------------------------------------------------------------------------- /cmd/control/internal/control/control.go: -------------------------------------------------------------------------------- 1 | package control 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | "uw/uboot" 10 | "uw/ulog" 11 | 12 | "nrat/pkg/nostr" 13 | "nrat/pkg/nostr/nip04" 14 | 15 | "nrat/model" 16 | "nrat/utils" 17 | ) 18 | 19 | func ControlUint(c *uboot.Context) (e error) { 20 | if e := c.Require(c.Context(), "unostr"); e != nil { 21 | return e 22 | } 23 | if e := c.Require(c.Context(), "storage"); e != nil { 24 | return e 25 | } 26 | 27 | storage, ok := utils.UbootGetAssert[model.Storage[*model.ControlStorageData]](c, "storage") 28 | if !ok { 29 | return errors.New("get storage failed") 30 | } 31 | unostr, ok := utils.UbootGetAssert[model.Unostr](c, "unostr") 32 | if !ok { 33 | return errors.New("get unostr failed") 34 | } 35 | 36 | control := &Control{ 37 | unostr: unostr, 38 | storage: storage, 39 | eventCh: make(chan *model.Event), 40 | } 41 | 42 | control.cmdTimeout, e = time.ParseDuration(storage.Storage().CmdTimeout) 43 | if e != nil || control.cmdTimeout < 1 { 44 | ulog.Warn("parse command timeout failed or timeout interval < 1, use default 10s") 45 | storage.Storage().CmdTimeout = "10s" 46 | control.cmdTimeout = 10 * time.Second 47 | 48 | if e := storage.Write(); e != nil { 49 | ulog.Warn("write storage failed: %s", e) 50 | } 51 | } 52 | 53 | if storage.Storage().ExecTimeout == "" { 54 | ulog.Warn("exec timeout is empty, use default 30s") 55 | storage.Storage().ExecTimeout = "30s" 56 | 57 | if e := storage.Write(); e != nil { 58 | ulog.Warn("write storage failed: %s", e) 59 | } 60 | } 61 | 62 | ulog.GlobalFormat().SetLevel(ulog.GlobalFormat().GetLevel() ^ ulog.LevelDebug) 63 | 64 | // c.Printf("control init success: %v", control) 65 | 66 | for { 67 | if e := loopHandler(control); e != nil && !errors.Is(e, ErrLoopExit) { 68 | ulog.Warn("loop handler failed: %s", e) 69 | } else if errors.Is(e, ErrLoopExit) { 70 | return nil 71 | } 72 | } 73 | } 74 | 75 | type Control struct { 76 | unostr model.Unostr 77 | privateKey string 78 | publishKey string 79 | shareKey []byte 80 | eventUnSub func() 81 | eventCh chan *model.Event 82 | storage model.Storage[*model.ControlStorageData] 83 | cmdTimeout time.Duration 84 | } 85 | 86 | func (control *Control) setPrivateKey(privateKey string) (e error) { 87 | control.privateKey = privateKey 88 | control.publishKey, e = nostr.GetPublicKey(privateKey) 89 | if e != nil { 90 | return fmt.Errorf("get public key failed: %w", e) 91 | } 92 | 93 | control.shareKey, e = nip04.ComputeSharedSecret(control.publishKey, privateKey) 94 | if e != nil { 95 | return fmt.Errorf("compute shared secret failed: %w", e) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (control *Control) subscribe() error { 102 | if control.eventUnSub != nil { 103 | control.eventUnSub() 104 | } 105 | 106 | now := nostr.Now() 107 | sub, e := control.unostr.Relay().Subscribe(context.Background(), []nostr.Filter{{ 108 | Kinds: []int{nostr.KindApplicationSpecificData}, 109 | Authors: []string{control.publishKey}, 110 | Tags: nostr.TagMap{"d": []string{"agent"}}, 111 | Since: &now, 112 | }}) 113 | if e != nil { 114 | return fmt.Errorf("subscribe failed: %w", e) 115 | } 116 | 117 | control.eventUnSub = sub.Unsub 118 | go control.subscribeRange(sub.Events) 119 | return nil 120 | } 121 | 122 | func (control *Control) subscribeRange(ch chan *nostr.Event) { 123 | for ev := range ch { 124 | message, e := nip04.Decrypt(ev.Content, control.shareKey) 125 | if e != nil { 126 | ulog.Warn("decrypt event failed: %s", e) 127 | continue 128 | } 129 | 130 | evt := &model.Event{ 131 | Id: ev.ID, 132 | } 133 | 134 | if e := evt.Decode(message); e != nil { 135 | ulog.Warn("decode event failed: %s", e) 136 | continue 137 | } 138 | 139 | evt.Content = strings.TrimSpace(evt.Content) 140 | 141 | select { 142 | case control.eventCh <- evt: 143 | case <-time.After(control.cmdTimeout / 2): 144 | ulog.Warn("event channel is full, discard event: %s", evt) 145 | } 146 | } 147 | } 148 | 149 | func (control *Control) publish(ctx context.Context, evt *model.Event) error { 150 | encMessage, e := nip04.Encrypt(evt.Encode(), control.shareKey) 151 | if e != nil { 152 | return fmt.Errorf("encrypt failed: %w", e) 153 | } 154 | 155 | ev := nostr.Event{ 156 | PubKey: control.publishKey, 157 | CreatedAt: nostr.Now(), 158 | Kind: nostr.KindApplicationSpecificData, 159 | Tags: nostr.Tags{{ 160 | "d", "control", 161 | }}, 162 | Content: encMessage, 163 | } 164 | 165 | if e := ev.Sign(control.privateKey); e != nil { 166 | fmt.Printf("failed to sign: %s\n", e) 167 | } 168 | 169 | ret, e := control.unostr.Relay().Publish(ctx, ev) 170 | if e != nil { 171 | return fmt.Errorf("publish failed: %w", e) 172 | } 173 | 174 | if ret < 0 { 175 | return fmt.Errorf("publish failed: %s", ret) 176 | } 177 | 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /pkg/unostr/unostr.go: -------------------------------------------------------------------------------- 1 | package unostr 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/url" 10 | "strings" 11 | "time" 12 | "uw/uboot" 13 | "uw/ulog" 14 | 15 | "nrat/pkg/nostr" 16 | 17 | "nrat/model" 18 | "nrat/utils" 19 | 20 | "golang.org/x/net/proxy" 21 | ) 22 | 23 | func UnostrUint(c *uboot.Context) error { 24 | if e := c.Require(c.Context(), "storage"); e != nil { 25 | return e 26 | } 27 | 28 | storage, ok := utils.UbootGetAssert[model.UnostrStorage](c, "storage") 29 | if !ok { 30 | return errors.New("get storage failed") 31 | } 32 | 33 | connectTimeout, e := time.ParseDuration(storage.Unostr().ConnectTimeout) 34 | if e != nil || connectTimeout < 1 { 35 | ulog.Warn("parse connect timeout failed or connect timeout < 1, use default 5s") 36 | connectTimeout = 5 * time.Second 37 | } 38 | storage.Unostr().ConnectTimeout = connectTimeout.String() 39 | 40 | pingInterval, e := time.ParseDuration(storage.Unostr().PingInterval) 41 | if e != nil || pingInterval < 1 { 42 | ulog.Warn("parse ping interval failed or ping interval < 1, use default 10s") 43 | pingInterval = 10 * time.Second 44 | } 45 | storage.Unostr().PingInterval = pingInterval.String() 46 | 47 | if e := storage.Write(); e != nil { 48 | return fmt.Errorf("write storage failed: %w", e) 49 | } 50 | 51 | u := &Unostr{ 52 | relayURL: strings.TrimSpace(storage.Unostr().Relay), 53 | proxyURL: strings.TrimSpace(storage.Unostr().Proxy), 54 | connectTimeout: connectTimeout, 55 | } 56 | 57 | if u.relayURL == "" { 58 | return errors.New("relay is empty") 59 | } 60 | 61 | c.Printf("relay: %s", u.relayURL) 62 | 63 | u.relay = nostr.NewRelay(context.Background(), u.relayURL) 64 | 65 | nostr.InfoLogger = log.New(io.Discard, "", log.LstdFlags) 66 | nostr.DebugLogger = log.New(io.Discard, "", log.LstdFlags) 67 | 68 | if u.proxyURL != "" { 69 | c.Printf("use proxy: %s", u.proxyURL) 70 | 71 | proxyURL, e := url.Parse(u.proxyURL) 72 | if e != nil { 73 | return fmt.Errorf("failed to parse proxy url: %w", e) 74 | } 75 | 76 | dialer, e := proxy.FromURL(proxyURL, proxy.Direct) 77 | if e != nil { 78 | return fmt.Errorf("failed to create dialer: %w", e) 79 | } 80 | 81 | contextDialer, ok := dialer.(proxy.ContextDialer) 82 | if !ok { 83 | return fmt.Errorf("failed to convert dialer to context dialer") 84 | } 85 | 86 | u.relay.Dial = contextDialer.DialContext 87 | } 88 | 89 | c.Printf("connect timeout: %s", u.connectTimeout) 90 | 91 | c.Printf("first connecting to %s...", u.relayURL) 92 | 93 | for { 94 | if e := u.Connect(); e != nil { 95 | c.Printf("first connect to %s failed: %s", u.relayURL, e) 96 | 97 | c.Printf("retrying after %s", u.connectTimeout) 98 | <-time.After(u.connectTimeout) 99 | c.Printf("ready retrying connect to %s", u.relayURL) 100 | continue 101 | } 102 | 103 | break 104 | } 105 | 106 | c.Printf("first connected to %s", u.relayURL) 107 | 108 | go u.pingLoop(pingInterval) 109 | 110 | c.Set("unostr", u) 111 | return nil 112 | } 113 | 114 | type Unostr struct { 115 | relayURL string // 中继器 116 | proxyURL string // 代理 117 | 118 | relay *nostr.Relay 119 | 120 | connectTimeout time.Duration // 连接超时 121 | pingTicker *time.Ticker 122 | connectEvent func() 123 | } 124 | 125 | func (u *Unostr) ConnectTimeout() time.Duration { 126 | return u.connectTimeout 127 | } 128 | 129 | func (u *Unostr) Connect() error { 130 | if u.relay.Connection != nil { 131 | u.relay.Connection.Close() 132 | } 133 | 134 | ctx, cancel := context.WithTimeout(context.Background(), u.connectTimeout) 135 | defer cancel() 136 | 137 | if e := u.relay.Connect(ctx); e != nil { 138 | return e 139 | } 140 | 141 | u.relay.Connection.SetRawLog(ulog.Debug) 142 | 143 | if u.connectEvent != nil { 144 | u.connectEvent() 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (u *Unostr) SetConnectEvent(f func()) { 151 | u.connectEvent = f 152 | } 153 | 154 | func (u *Unostr) Relay() *nostr.Relay { 155 | return u.relay 156 | } 157 | 158 | func (u *Unostr) Close() error { 159 | if u.pingTicker != nil { 160 | u.pingTicker.Stop() 161 | } 162 | 163 | if u.relay.Connection != nil { 164 | if e := u.relay.Connection.Close(); e != nil { 165 | return e 166 | } 167 | } 168 | 169 | if e := u.relay.Close(); e != nil { 170 | return e 171 | } 172 | 173 | return nil 174 | } 175 | 176 | func (u *Unostr) pingLoop(pingInterval time.Duration) { 177 | if u.pingTicker != nil { 178 | u.pingTicker.Stop() 179 | } 180 | 181 | u.pingTicker = time.NewTicker(pingInterval) 182 | for range u.pingTicker.C { 183 | if u.relay.Connection != nil { 184 | if u.relay.Connection.Ping() == nil { 185 | // ulog.Debug("ping %s success", u.relayURL) 186 | continue 187 | } 188 | } 189 | 190 | ulog.Warn("reconnect to %s", u.relayURL) 191 | 192 | if e := u.Connect(); e != nil { 193 | ulog.Error("failed to reconnect to %s: %s", u.relayURL, e.Error()) 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /pkg/nostr/event.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | 8 | "github.com/btcsuite/btcd/btcec/v2" 9 | "github.com/btcsuite/btcd/btcec/v2/schnorr" 10 | "github.com/mailru/easyjson" 11 | ) 12 | 13 | type Event struct { 14 | ID string `json:"id"` 15 | PubKey string `json:"pubkey"` 16 | CreatedAt Timestamp `json:"created_at"` 17 | Kind int `json:"kind"` 18 | Tags Tags `json:"tags"` 19 | Content string `json:"content"` 20 | Sig string `json:"sig"` 21 | 22 | // anything here will be mashed together with the main event object when serializing 23 | extra map[string]any 24 | } 25 | 26 | const ( 27 | KindSetMetadata int = 0 28 | KindTextNote int = 1 29 | KindRecommendServer int = 2 30 | KindContactList int = 3 31 | KindEncryptedDirectMessage int = 4 32 | KindDeletion int = 5 33 | KindBoost int = 6 34 | KindReaction int = 7 35 | KindChannelCreation int = 40 36 | KindChannelMetadata int = 41 37 | KindChannelMessage int = 42 38 | KindChannelHideMessage int = 43 39 | KindChannelMuteUser int = 44 40 | KindFileMetadata int = 1063 41 | KindZapRequest int = 9734 42 | KindZap int = 9735 43 | KindMuteList int = 10000 44 | KindPinList int = 10001 45 | KindRelayListMetadata int = 10002 46 | KindNWCWalletInfo int = 13194 47 | KindClientAuthentication int = 22242 48 | KindNWCWalletRequest int = 23194 49 | KindNWCWalletResponse int = 23195 50 | KindNostrConnect int = 24133 51 | KindCategorizedPeopleList int = 30000 52 | KindCategorizedBookmarksList int = 30001 53 | KindProfileBadges int = 30008 54 | KindBadgeDefinition int = 30009 55 | KindStallDefinition int = 30017 56 | KindProductDefinition int = 30018 57 | KindArticle int = 30023 58 | KindApplicationSpecificData int = 30078 59 | ) 60 | 61 | // Event Stringer interface, just returns the raw JSON as a string 62 | func (evt Event) String() string { 63 | j, _ := easyjson.Marshal(evt) 64 | return string(j) 65 | } 66 | 67 | // GetID serializes and returns the event ID as a string 68 | func (evt *Event) GetID() string { 69 | h := sha256.Sum256(evt.Serialize()) 70 | return hex.EncodeToString(h[:]) 71 | } 72 | 73 | // Serialize outputs a byte array that can be hashed/signed to identify/authenticate. 74 | // JSON encoding as defined in RFC4627. 75 | func (evt *Event) Serialize() []byte { 76 | // the serialization process is just putting everything into a JSON array 77 | // so the order is kept. See NIP-01 78 | dst := make([]byte, 0) 79 | 80 | // the header portion is easy to serialize 81 | // [0,"pubkey",created_at,kind,[ 82 | dst = append(dst, []byte( 83 | fmt.Sprintf( 84 | "[0,\"%s\",%d,%d,", 85 | evt.PubKey, 86 | evt.CreatedAt, 87 | evt.Kind, 88 | ))...) 89 | 90 | // tags 91 | dst = evt.Tags.marshalTo(dst) 92 | dst = append(dst, ',') 93 | 94 | // content needs to be escaped in general as it is user generated. 95 | dst = escapeString(dst, evt.Content) 96 | dst = append(dst, ']') 97 | 98 | return dst 99 | } 100 | 101 | // CheckSignature checks if the signature is valid for the id 102 | // (which is a hash of the serialized event content). 103 | // returns an error if the signature itself is invalid. 104 | func (evt Event) CheckSignature() (bool, error) { 105 | // read and check pubkey 106 | pk, err := hex.DecodeString(evt.PubKey) 107 | if err != nil { 108 | return false, fmt.Errorf("event pubkey '%s' is invalid hex: %w", evt.PubKey, err) 109 | } 110 | 111 | pubkey, err := schnorr.ParsePubKey(pk) 112 | if err != nil { 113 | return false, fmt.Errorf("event has invalid pubkey '%s': %w", evt.PubKey, err) 114 | } 115 | 116 | // read signature 117 | s, err := hex.DecodeString(evt.Sig) 118 | if err != nil { 119 | return false, fmt.Errorf("signature '%s' is invalid hex: %w", evt.Sig, err) 120 | } 121 | sig, err := schnorr.ParseSignature(s) 122 | if err != nil { 123 | return false, fmt.Errorf("failed to parse signature: %w", err) 124 | } 125 | 126 | // check signature 127 | hash := sha256.Sum256(evt.Serialize()) 128 | return sig.Verify(hash[:], pubkey), nil 129 | } 130 | 131 | // Sign signs an event with a given privateKey 132 | func (evt *Event) Sign(privateKey string) error { 133 | s, err := hex.DecodeString(privateKey) 134 | if err != nil { 135 | return fmt.Errorf("Sign called with invalid private key '%s': %w", privateKey, err) 136 | } 137 | 138 | sk, pk := btcec.PrivKeyFromBytes(s) 139 | pkBytes := pk.SerializeCompressed() 140 | evt.PubKey = hex.EncodeToString(pkBytes[1:]) 141 | 142 | h := sha256.Sum256(evt.Serialize()) 143 | sig, err := schnorr.Sign(sk, h[:]) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | evt.ID = hex.EncodeToString(h[:]) 149 | evt.Sig = hex.EncodeToString(sig.Serialize()) 150 | return nil 151 | } 152 | -------------------------------------------------------------------------------- /pkg/nostr/envelopes_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestEventEnvelopeEncodingAndDecoding(t *testing.T) { 9 | eventEnvelopes := []string{ 10 | `["EVENT","_",{"id":"dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1644271588,"kind":1,"tags":[],"content":"now that https://blueskyweb.org/blog/2-7-2022-overview was announced we can stop working on nostr?","sig":"230e9d8f0ddaf7eb70b5f7741ccfa37e87a455c9a469282e3464e2052d3192cd63a167e196e381ef9d7e69e9ea43af2443b839974dc85d8aaab9efe1d9296524"}]`, 11 | `["EVENT",{"id":"9e662bdd7d8abc40b5b15ee1ff5e9320efc87e9274d8d440c58e6eed2dddfbe2","pubkey":"373ebe3d45ec91977296a178d9f19f326c70631d2a1b0bbba5c5ecc2eb53b9e7","created_at":1644844224,"kind":3,"tags":[["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","75fc5ac2487363293bd27fb0d14fb966477d0f1dbc6361d37806a6a740eda91e"],["p","46d0dfd3a724a302ca9175163bdf788f3606b3fd1bb12d5fe055d1e418cb60ea"]],"content":"{\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social\":{\"read\":false,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relay.bitid.nz\":{\"read\":true,\"write\":true},\"wss://nostr.rocks\":{\"read\":true,\"write\":true}}","sig":"811355d3484d375df47581cb5d66bed05002c2978894098304f20b595e571b7e01b2efd906c5650080ffe49cf1c62b36715698e9d88b9e8be43029a2f3fa66be"}]`, 12 | } 13 | 14 | for _, raw := range eventEnvelopes { 15 | var env EventEnvelope 16 | err := json.Unmarshal([]byte(raw), &env) 17 | if err != nil { 18 | t.Errorf("failed to parse event envelope json: %v", err) 19 | } 20 | 21 | if env.GetID() != env.ID { 22 | t.Errorf("error serializing event id: %s != %s", env.GetID(), env.ID) 23 | } 24 | 25 | if ok, _ := env.CheckSignature(); !ok { 26 | t.Error("signature verification failed when it should have succeeded") 27 | } 28 | 29 | asjson, err := json.Marshal(env) 30 | if err != nil { 31 | t.Errorf("failed to re marshal event as json: %v", err) 32 | } 33 | 34 | if string(asjson) != raw { 35 | t.Log(string(asjson)) 36 | t.Error("json serialization broken") 37 | } 38 | } 39 | } 40 | 41 | func TestNoticeEnvelopeEncodingAndDecoding(t *testing.T) { 42 | src := `["NOTICE","kjasbdlasvdluiasvd\"kjasbdksab\\d"]` 43 | var env NoticeEnvelope 44 | json.Unmarshal([]byte(src), &env) 45 | if env != "kjasbdlasvdluiasvd\"kjasbdksab\\d" { 46 | t.Error("failed to decode NOTICE") 47 | } 48 | 49 | res, _ := json.Marshal(env) 50 | if string(res) != src { 51 | t.Errorf("failed to encode NOTICE: expected '%s', got '%s'", src, string(res)) 52 | } 53 | } 54 | 55 | func TestEoseEnvelopeEncodingAndDecoding(t *testing.T) { 56 | src := `["EOSE","kjasbdlasvdluiasvd\"kjasbdksab\\d"]` 57 | var env EOSEEnvelope 58 | json.Unmarshal([]byte(src), &env) 59 | if env != "kjasbdlasvdluiasvd\"kjasbdksab\\d" { 60 | t.Error("failed to decode EOSE") 61 | } 62 | 63 | res, _ := json.Marshal(env) 64 | if string(res) != src { 65 | t.Errorf("failed to encode EOSE: expected '%s', got '%s'", src, string(res)) 66 | } 67 | } 68 | 69 | func TestOKEnvelopeEncodingAndDecoding(t *testing.T) { 70 | okEnvelopes := []string{ 71 | `["OK","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefaaaaa",false,"error: could not connect to the database"]`, 72 | `["OK","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefaaaaa",true]`, 73 | } 74 | 75 | for _, raw := range okEnvelopes { 76 | var env OKEnvelope 77 | err := json.Unmarshal([]byte(raw), &env) 78 | if err != nil { 79 | t.Errorf("failed to parse ok envelope json: %v", err) 80 | } 81 | 82 | asjson, err := json.Marshal(env) 83 | if err != nil { 84 | t.Errorf("failed to re marshal ok as json: %v", err) 85 | } 86 | 87 | if string(asjson) != raw { 88 | t.Log(string(asjson)) 89 | t.Error("json serialization broken") 90 | } 91 | } 92 | } 93 | 94 | func TestAuthEnvelopeEncodingAndDecoding(t *testing.T) { 95 | authEnvelopes := []string{ 96 | `["AUTH","kjsabdlasb aslkd kasndkad \"as.kdnbskadb"]`, 97 | `["AUTH",{"id":"ae1fc7154296569d87ca4663f6bdf448c217d1590d28c85d158557b8b43b4d69","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1683660344,"kind":1,"tags":[],"content":"hello world","sig":"94e10947814b1ebe38af42300ecd90c7642763896c4f69506ae97bfdf54eec3c0c21df96b7d95daa74ff3d414b1d758ee95fc258125deebc31df0c6ba9396a51"}]`, 98 | } 99 | 100 | for _, raw := range authEnvelopes { 101 | var env AuthEnvelope 102 | err := json.Unmarshal([]byte(raw), &env) 103 | if err != nil { 104 | t.Errorf("failed to parse auth envelope json: %v", err) 105 | } 106 | 107 | asjson, err := json.Marshal(env) 108 | if err != nil { 109 | t.Errorf("failed to re marshal auth as json: %v", err) 110 | } 111 | 112 | if string(asjson) != raw { 113 | t.Log(string(asjson)) 114 | t.Error("json serialization broken") 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/nostr/event_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. 2 | 3 | package nostr 4 | 5 | import ( 6 | json "encoding/json" 7 | 8 | easyjson "github.com/mailru/easyjson" 9 | jlexer "github.com/mailru/easyjson/jlexer" 10 | jwriter "github.com/mailru/easyjson/jwriter" 11 | ) 12 | 13 | // suppress unused package warning 14 | var ( 15 | _ *json.RawMessage 16 | _ *jlexer.Lexer 17 | _ *jwriter.Writer 18 | _ easyjson.Marshaler 19 | ) 20 | 21 | func easyjsonF642ad3eDecodeGithubComNbdWtfGoNostr(in *jlexer.Lexer, out *Event) { 22 | isTopLevel := in.IsStart() 23 | if in.IsNull() { 24 | if isTopLevel { 25 | in.Consumed() 26 | } 27 | in.Skip() 28 | return 29 | } 30 | out.extra = make(map[string]any) 31 | in.Delim('{') 32 | for !in.IsDelim('}') { 33 | key := in.UnsafeFieldName(true) 34 | in.WantColon() 35 | if in.IsNull() { 36 | in.Skip() 37 | in.WantComma() 38 | continue 39 | } 40 | switch key { 41 | case "id": 42 | out.ID = in.UnsafeString() 43 | case "pubkey": 44 | out.PubKey = in.UnsafeString() 45 | case "created_at": 46 | out.CreatedAt = Timestamp(in.Int64()) 47 | case "kind": 48 | out.Kind = in.Int() 49 | case "tags": 50 | if in.IsNull() { 51 | in.Skip() 52 | out.Tags = nil 53 | } else { 54 | in.Delim('[') 55 | if out.Tags == nil { 56 | if !in.IsDelim(']') { 57 | out.Tags = make(Tags, 0, 7) 58 | } else { 59 | out.Tags = Tags{} 60 | } 61 | } else { 62 | out.Tags = (out.Tags)[:0] 63 | } 64 | for !in.IsDelim(']') { 65 | var v1 Tag 66 | if in.IsNull() { 67 | in.Skip() 68 | v1 = nil 69 | } else { 70 | in.Delim('[') 71 | if v1 == nil { 72 | if !in.IsDelim(']') { 73 | v1 = make(Tag, 0, 5) 74 | } else { 75 | v1 = Tag{} 76 | } 77 | } else { 78 | v1 = (v1)[:0] 79 | } 80 | for !in.IsDelim(']') { 81 | var v2 string 82 | v2 = string(in.String()) 83 | v1 = append(v1, v2) 84 | in.WantComma() 85 | } 86 | in.Delim(']') 87 | } 88 | out.Tags = append(out.Tags, v1) 89 | in.WantComma() 90 | } 91 | in.Delim(']') 92 | } 93 | case "content": 94 | out.Content = in.UnsafeString() 95 | case "sig": 96 | out.Sig = in.UnsafeString() 97 | default: 98 | out.extra[key] = in.Interface() 99 | } 100 | in.WantComma() 101 | } 102 | in.Delim('}') 103 | if isTopLevel { 104 | in.Consumed() 105 | } 106 | } 107 | 108 | func easyjsonF642ad3eEncodeGithubComNbdWtfGoNostr(out *jwriter.Writer, in Event) { 109 | out.RawByte('{') 110 | first := true 111 | _ = first 112 | { 113 | const prefix string = ",\"id\":" 114 | out.RawString(prefix[1:]) 115 | out.String(in.ID) 116 | } 117 | { 118 | const prefix string = ",\"pubkey\":" 119 | out.RawString(prefix) 120 | out.String(in.PubKey) 121 | } 122 | { 123 | const prefix string = ",\"created_at\":" 124 | out.RawString(prefix) 125 | out.Int64(int64(in.CreatedAt)) 126 | } 127 | { 128 | const prefix string = ",\"kind\":" 129 | out.RawString(prefix) 130 | out.Int(in.Kind) 131 | } 132 | { 133 | const prefix string = ",\"tags\":" 134 | out.RawString(prefix) 135 | if in.Tags == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 136 | out.RawString("null") 137 | } else { 138 | out.RawByte('[') 139 | for v3, v4 := range in.Tags { 140 | if v3 > 0 { 141 | out.RawByte(',') 142 | } 143 | if v4 == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { 144 | out.RawString("null") 145 | } else { 146 | out.RawByte('[') 147 | for v5, v6 := range v4 { 148 | if v5 > 0 { 149 | out.RawByte(',') 150 | } 151 | out.String(string(v6)) 152 | } 153 | out.RawByte(']') 154 | } 155 | } 156 | out.RawByte(']') 157 | } 158 | } 159 | { 160 | const prefix string = ",\"content\":" 161 | out.RawString(prefix) 162 | out.String(in.Content) 163 | } 164 | { 165 | const prefix string = ",\"sig\":" 166 | out.RawString(prefix) 167 | out.String(in.Sig) 168 | } 169 | { 170 | for key, value := range in.extra { 171 | out.RawString(",\"" + key + "\":") 172 | out.Raw(json.Marshal(value)) 173 | } 174 | } 175 | out.RawByte('}') 176 | } 177 | 178 | // MarshalJSON supports json.Marshaler interface 179 | func (v Event) MarshalJSON() ([]byte, error) { 180 | w := jwriter.Writer{} 181 | easyjsonF642ad3eEncodeGithubComNbdWtfGoNostr(&w, v) 182 | return w.Buffer.BuildBytes(), w.Error 183 | } 184 | 185 | // MarshalEasyJSON supports easyjson.Marshaler interface 186 | func (v Event) MarshalEasyJSON(w *jwriter.Writer) { 187 | easyjsonF642ad3eEncodeGithubComNbdWtfGoNostr(w, v) 188 | } 189 | 190 | // UnmarshalJSON supports json.Unmarshaler interface 191 | func (v *Event) UnmarshalJSON(data []byte) error { 192 | r := jlexer.Lexer{Data: data} 193 | easyjsonF642ad3eDecodeGithubComNbdWtfGoNostr(&r, v) 194 | return r.Error() 195 | } 196 | 197 | // UnmarshalEasyJSON supports easyjson.Unmarshaler interface 198 | func (v *Event) UnmarshalEasyJSON(l *jlexer.Lexer) { 199 | easyjsonF642ad3eDecodeGithubComNbdWtfGoNostr(l, v) 200 | } 201 | -------------------------------------------------------------------------------- /cmd/agent/internal/agent/handler.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "nrat/model" 16 | 17 | "github.com/atotto/clipboard" 18 | ) 19 | 20 | type handler func(agent *Agent, ev *model.Event) (string, error) 21 | 22 | var agentHandlers = map[string]handler{ 23 | "info": infoHandler, 24 | "ping": pingHandler, 25 | "list": listHandler, 26 | "read": readHandler, 27 | "write": writeHandler, 28 | "mkdir": mkdirHandler, 29 | "rename": renameHandler, 30 | "remove": removeHandler, 31 | "exec": execHandler, 32 | "clipboard": clipboardHandler, 33 | } 34 | 35 | func infoHandler(agent *Agent, ev *model.Event) (string, error) { 36 | return strings.Join([]string{ 37 | runtime.GOOS, 38 | runtime.GOARCH, 39 | strconv.Itoa(runtime.NumCPU()), 40 | runtime.Version(), 41 | agent.storage.Storage().Relay, 42 | agent.storage.Storage().Proxy, 43 | agent.storage.Storage().PrivateKey, 44 | }, model.DataSeparator), nil 45 | } 46 | 47 | func pingHandler(agent *Agent, ev *model.Event) (string, error) { 48 | if ev.Content != "" { 49 | return ev.Content, nil 50 | } 51 | 52 | return "none", nil 53 | } 54 | 55 | func listHandler(agent *Agent, ev *model.Event) (string, error) { 56 | if ev.Content == "" { 57 | ev.Content = "." 58 | } 59 | 60 | l, e := os.ReadDir(ev.Content) 61 | if e != nil { 62 | return "", e 63 | } 64 | 65 | files := []string{} 66 | for _, f := range l { 67 | files = append(files, func() string { 68 | if f.IsDir() { 69 | return f.Name() + "/" 70 | } 71 | 72 | return f.Name() 73 | }()) 74 | } 75 | 76 | return strings.Join(files, model.DataSeparator), nil 77 | } 78 | 79 | func readHandler(agent *Agent, ev *model.Event) (string, error) { 80 | if ev.Content == "" { 81 | return "", errors.New("empty file path") 82 | } 83 | 84 | b, e := os.ReadFile(ev.Content) 85 | if e != nil { 86 | return "", e 87 | } 88 | 89 | return base64.StdEncoding.EncodeToString(b), nil 90 | } 91 | 92 | func writeHandler(agent *Agent, ev *model.Event) (string, error) { 93 | n := strings.LastIndex(ev.Content, model.DataSeparator) 94 | if n < 1 { 95 | return "", errors.New("invalid file data") 96 | } 97 | 98 | b, e := base64.StdEncoding.DecodeString(ev.Content[n+1:]) 99 | if e != nil { 100 | return "", e 101 | } 102 | 103 | if e := os.WriteFile(ev.Content[:n], b, 0o755); e != nil { 104 | return "", e 105 | } 106 | 107 | return "ok", nil 108 | } 109 | 110 | func mkdirHandler(agent *Agent, ev *model.Event) (string, error) { 111 | if ev.Content == "" { 112 | return "", errors.New("empty dir path") 113 | } 114 | 115 | if e := os.MkdirAll(ev.Content, 0o755); e != nil { 116 | return "", e 117 | } 118 | 119 | return "ok", nil 120 | } 121 | 122 | func renameHandler(agent *Agent, ev *model.Event) (string, error) { 123 | if ev.Content == "" { 124 | return "", errors.New("empty file path") 125 | } 126 | 127 | n := strings.SplitN(ev.Content, model.DataSeparator, 2) 128 | if len(n) < 2 { 129 | return "", errors.New("invalid file path") 130 | } 131 | 132 | if e := os.Rename(n[0], n[1]); e != nil { 133 | return "", e 134 | } 135 | 136 | return "ok", nil 137 | } 138 | 139 | func removeHandler(agent *Agent, ev *model.Event) (string, error) { 140 | if ev.Content == "" { 141 | return "", errors.New("empty file path") 142 | } 143 | 144 | if e := os.RemoveAll(ev.Content); e != nil { 145 | return "", e 146 | } 147 | 148 | return "ok", nil 149 | } 150 | 151 | func execHandler(agent *Agent, ev *model.Event) (string, error) { 152 | if ev.Content == "" { 153 | return "", errors.New("empty command") 154 | } 155 | 156 | cmd := strings.Split(ev.Content, model.DataSeparator) 157 | if len(cmd) < 2 { 158 | return "", errors.New("invalid command") 159 | } 160 | 161 | t, e := time.ParseDuration(cmd[0]) 162 | if e != nil { 163 | return "", fmt.Errorf("invalid timeout: %w", e) 164 | } 165 | 166 | ctx, cancel := context.WithTimeout(context.Background(), t) 167 | defer cancel() 168 | 169 | b, e := exec.CommandContext(ctx, cmd[1], cmd[2:]...).CombinedOutput() 170 | if e != nil { 171 | return "", e 172 | } 173 | 174 | if len(b) == 0 { 175 | b = []byte("success") 176 | } 177 | 178 | return base64.StdEncoding.EncodeToString(b), nil 179 | } 180 | 181 | func clipboardHandler(agent *Agent, ev *model.Event) (string, error) { 182 | n := strings.Split(ev.Content, model.DataSeparator) 183 | if len(n) < 1 && (n[0] == "set" && len(n) < 2) { 184 | return "", errors.New("invalid clipboard data") 185 | } 186 | 187 | switch n[0] { 188 | case "set": 189 | b, e := base64.StdEncoding.DecodeString(n[1]) 190 | if e != nil { 191 | return "", e 192 | } 193 | 194 | if e := clipboard.WriteAll(string(b)); e != nil { 195 | return "", e 196 | } 197 | 198 | return "ok", nil 199 | case "get": 200 | b, e := clipboard.ReadAll() 201 | if e != nil { 202 | return "", e 203 | } 204 | 205 | return base64.StdEncoding.EncodeToString([]byte(b)), nil 206 | } 207 | 208 | return "", errors.New("invalid clipboard command") 209 | } 210 | -------------------------------------------------------------------------------- /pkg/nostr/connection.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "sync" 13 | 14 | "github.com/gobwas/httphead" 15 | "github.com/gobwas/ws" 16 | "github.com/gobwas/ws/wsflate" 17 | "github.com/gobwas/ws/wsutil" 18 | ) 19 | 20 | type Connection struct { 21 | conn net.Conn 22 | enableCompression bool 23 | controlHandler wsutil.FrameHandlerFunc 24 | flateReader *wsflate.Reader 25 | reader *wsutil.Reader 26 | flateWriter *wsflate.Writer 27 | writer *wsutil.Writer 28 | msgState *wsflate.MessageState 29 | mutex sync.Mutex 30 | rawLog func(format string, v ...interface{}) 31 | } 32 | 33 | func NewConnection(ctx context.Context, serverURL string, 34 | dial func(ctx context.Context, network, addr string) (net.Conn, error), 35 | requestHeader http.Header, 36 | ) (*Connection, error) { 37 | dialer := ws.Dialer{ 38 | Header: ws.HandshakeHeaderHTTP(requestHeader), 39 | Extensions: []httphead.Option{ 40 | wsflate.DefaultParameters.Option(), 41 | }, 42 | } 43 | 44 | if dial != nil { 45 | dialer.NetDial = dial 46 | } 47 | 48 | conn, _, hs, err := dialer.Dial(ctx, serverURL) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to dial: %w", err) 51 | } 52 | 53 | enableCompression := false 54 | state := ws.StateClientSide 55 | for _, extension := range hs.Extensions { 56 | if string(extension.Name) == wsflate.ExtensionName { 57 | enableCompression = true 58 | state |= ws.StateExtended 59 | break 60 | } 61 | } 62 | 63 | // reader 64 | var flateReader *wsflate.Reader 65 | var msgState wsflate.MessageState 66 | if enableCompression { 67 | msgState.SetCompressed(true) 68 | 69 | flateReader = wsflate.NewReader(nil, func(r io.Reader) wsflate.Decompressor { 70 | return flate.NewReader(r) 71 | }) 72 | } 73 | 74 | controlHandler := wsutil.ControlFrameHandler(conn, ws.StateClientSide) 75 | reader := &wsutil.Reader{ 76 | Source: conn, 77 | State: state, 78 | OnIntermediate: controlHandler, 79 | CheckUTF8: false, 80 | Extensions: []wsutil.RecvExtension{ 81 | &msgState, 82 | }, 83 | } 84 | 85 | // writer 86 | var flateWriter *wsflate.Writer 87 | if enableCompression { 88 | flateWriter = wsflate.NewWriter(nil, func(w io.Writer) wsflate.Compressor { 89 | fw, err := flate.NewWriter(w, 4) 90 | if err != nil { 91 | InfoLogger.Printf("Failed to create flate writer: %v", err) 92 | } 93 | return fw 94 | }) 95 | } 96 | 97 | writer := wsutil.NewWriter( 98 | conn, 99 | state, 100 | ws.OpText, 101 | ) 102 | writer.SetExtensions(&msgState) 103 | 104 | return &Connection{ 105 | conn: conn, 106 | enableCompression: enableCompression, 107 | controlHandler: controlHandler, 108 | flateReader: flateReader, 109 | reader: reader, 110 | flateWriter: flateWriter, 111 | msgState: &msgState, 112 | writer: writer, 113 | }, nil 114 | } 115 | 116 | func (c *Connection) SetRawLog(l func(format string, args ...interface{})) { 117 | c.rawLog = l 118 | } 119 | 120 | func (c *Connection) Ping() error { 121 | c.mutex.Lock() 122 | defer c.mutex.Unlock() 123 | 124 | return wsutil.WriteClientMessage(c.conn, ws.OpPing, nil) 125 | } 126 | 127 | func (c *Connection) WriteMessage(data []byte) error { 128 | c.mutex.Lock() 129 | defer c.mutex.Unlock() 130 | 131 | if c.rawLog != nil { 132 | c.rawLog("writing message: %s\n", string(data)) 133 | } 134 | 135 | if c.msgState.IsCompressed() && c.enableCompression { 136 | c.flateWriter.Reset(c.writer) 137 | if _, err := io.Copy(c.flateWriter, bytes.NewReader(data)); err != nil { 138 | return fmt.Errorf("failed to write message: %w", err) 139 | } 140 | 141 | err := c.flateWriter.Close() 142 | if err != nil { 143 | return fmt.Errorf("failed to close flate writer: %w", err) 144 | } 145 | } else { 146 | if _, err := io.Copy(c.writer, bytes.NewReader(data)); err != nil { 147 | return fmt.Errorf("failed to write message: %w", err) 148 | } 149 | } 150 | 151 | err := c.writer.Flush() 152 | if err != nil { 153 | return fmt.Errorf("failed to flush writer: %w", err) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *Connection) ReadMessage(ctx context.Context) ([]byte, error) { 160 | for { 161 | select { 162 | case <-ctx.Done(): 163 | return nil, errors.New("context canceled") 164 | default: 165 | } 166 | 167 | h, err := c.reader.NextFrame() 168 | if err != nil { 169 | c.conn.Close() 170 | return nil, fmt.Errorf("failed to advance frame: %w", err) 171 | } 172 | 173 | if h.OpCode.IsControl() { 174 | if err := c.controlHandler(h, c.reader); err != nil { 175 | return nil, fmt.Errorf("failed to handle control frame: %w", err) 176 | } 177 | } else if h.OpCode == ws.OpBinary || 178 | h.OpCode == ws.OpText { 179 | break 180 | } 181 | 182 | if err := c.reader.Discard(); err != nil { 183 | return nil, fmt.Errorf("failed to discard: %w", err) 184 | } 185 | } 186 | 187 | buf := new(bytes.Buffer) 188 | if c.msgState.IsCompressed() && c.enableCompression { 189 | c.flateReader.Reset(c.reader) 190 | if _, err := io.Copy(buf, c.flateReader); err != nil { 191 | return nil, fmt.Errorf("failed to read message: %w", err) 192 | } 193 | } else { 194 | if _, err := io.Copy(buf, c.reader); err != nil { 195 | return nil, fmt.Errorf("failed to read message: %w", err) 196 | } 197 | } 198 | 199 | if c.rawLog != nil { 200 | c.rawLog("read message: %s\n", buf.String()) 201 | } 202 | 203 | return buf.Bytes(), nil 204 | } 205 | 206 | func (c *Connection) Close() error { 207 | return c.conn.Close() 208 | } 209 | -------------------------------------------------------------------------------- /pkg/nostr/nip19/nip19.go: -------------------------------------------------------------------------------- 1 | package nip19 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | 9 | "nrat/pkg/nostr" 10 | 11 | "github.com/btcsuite/btcd/btcutil/bech32" 12 | ) 13 | 14 | func Decode(bech32string string) (prefix string, value any, err error) { 15 | prefix, bits5, err := bech32.DecodeNoLimit(bech32string) 16 | if err != nil { 17 | return "", nil, err 18 | } 19 | 20 | data, err := bech32.ConvertBits(bits5, 5, 8, false) 21 | if err != nil { 22 | return prefix, nil, fmt.Errorf("failed translating data into 8 bits: %s", err.Error()) 23 | } 24 | 25 | switch prefix { 26 | case "npub", "nsec", "note": 27 | if len(data) < 32 { 28 | return prefix, nil, fmt.Errorf("data is less than 32 bytes (%d)", len(data)) 29 | } 30 | 31 | return prefix, hex.EncodeToString(data[0:32]), nil 32 | case "nprofile": 33 | var result nostr.ProfilePointer 34 | curr := 0 35 | for { 36 | t, v := readTLVEntry(data[curr:]) 37 | if v == nil { 38 | // end here 39 | if result.PublicKey == "" { 40 | return prefix, result, fmt.Errorf("no pubkey found for nprofile") 41 | } 42 | 43 | return prefix, result, nil 44 | } 45 | 46 | switch t { 47 | case TLVDefault: 48 | result.PublicKey = hex.EncodeToString(v) 49 | case TLVRelay: 50 | result.Relays = append(result.Relays, string(v)) 51 | default: 52 | // ignore 53 | } 54 | 55 | curr = curr + 2 + len(v) 56 | } 57 | case "nevent": 58 | var result nostr.EventPointer 59 | curr := 0 60 | for { 61 | t, v := readTLVEntry(data[curr:]) 62 | if v == nil { 63 | // end here 64 | if result.ID == "" { 65 | return prefix, result, fmt.Errorf("no id found for nevent") 66 | } 67 | 68 | return prefix, result, nil 69 | } 70 | 71 | switch t { 72 | case TLVDefault: 73 | result.ID = hex.EncodeToString(v) 74 | case TLVRelay: 75 | result.Relays = append(result.Relays, string(v)) 76 | case TLVAuthor: 77 | result.Author = hex.EncodeToString(v) 78 | case TLVKind: 79 | result.Kind = int(binary.BigEndian.Uint32(v)) 80 | default: 81 | // ignore 82 | } 83 | 84 | curr = curr + 2 + len(v) 85 | } 86 | case "naddr": 87 | var result nostr.EntityPointer 88 | curr := 0 89 | for { 90 | t, v := readTLVEntry(data[curr:]) 91 | if v == nil { 92 | // end here 93 | if result.Kind == 0 || result.Identifier == "" || result.PublicKey == "" { 94 | return prefix, result, fmt.Errorf("incomplete naddr") 95 | } 96 | 97 | return prefix, result, nil 98 | } 99 | 100 | switch t { 101 | case TLVDefault: 102 | result.Identifier = string(v) 103 | case TLVRelay: 104 | result.Relays = append(result.Relays, string(v)) 105 | case TLVAuthor: 106 | result.PublicKey = hex.EncodeToString(v) 107 | case TLVKind: 108 | result.Kind = int(binary.BigEndian.Uint32(v)) 109 | default: 110 | // ignore 111 | } 112 | 113 | curr = curr + 2 + len(v) 114 | } 115 | } 116 | 117 | return prefix, data, fmt.Errorf("unknown tag %s", prefix) 118 | } 119 | 120 | func EncodePrivateKey(privateKeyHex string) (string, error) { 121 | b, err := hex.DecodeString(privateKeyHex) 122 | if err != nil { 123 | return "", fmt.Errorf("failed to decode private key hex: %w", err) 124 | } 125 | 126 | bits5, err := bech32.ConvertBits(b, 8, 5, true) 127 | if err != nil { 128 | return "", err 129 | } 130 | 131 | return bech32.Encode("nsec", bits5) 132 | } 133 | 134 | func EncodePublicKey(publicKeyHex string) (string, error) { 135 | b, err := hex.DecodeString(publicKeyHex) 136 | if err != nil { 137 | return "", fmt.Errorf("failed to decode public key hex: %w", err) 138 | } 139 | 140 | bits5, err := bech32.ConvertBits(b, 8, 5, true) 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | return bech32.Encode("npub", bits5) 146 | } 147 | 148 | func EncodeNote(eventIdHex string) (string, error) { 149 | b, err := hex.DecodeString(eventIdHex) 150 | if err != nil { 151 | return "", fmt.Errorf("failed to decode event id hex: %w", err) 152 | } 153 | 154 | bits5, err := bech32.ConvertBits(b, 8, 5, true) 155 | if err != nil { 156 | return "", err 157 | } 158 | 159 | return bech32.Encode("note", bits5) 160 | } 161 | 162 | func EncodeProfile(publicKeyHex string, relays []string) (string, error) { 163 | buf := &bytes.Buffer{} 164 | pubkey, err := hex.DecodeString(publicKeyHex) 165 | if err != nil { 166 | return "", fmt.Errorf("invalid pubkey '%s': %w", publicKeyHex, err) 167 | } 168 | writeTLVEntry(buf, TLVDefault, pubkey) 169 | 170 | for _, url := range relays { 171 | writeTLVEntry(buf, TLVRelay, []byte(url)) 172 | } 173 | 174 | bits5, err := bech32.ConvertBits(buf.Bytes(), 8, 5, true) 175 | if err != nil { 176 | return "", fmt.Errorf("failed to convert bits: %w", err) 177 | } 178 | 179 | return bech32.Encode("nprofile", bits5) 180 | } 181 | 182 | func EncodeEvent(eventIdHex string, relays []string, author string) (string, error) { 183 | buf := &bytes.Buffer{} 184 | id, err := hex.DecodeString(eventIdHex) 185 | if err != nil || len(id) != 32 { 186 | return "", fmt.Errorf("invalid id '%s': %w", eventIdHex, err) 187 | } 188 | writeTLVEntry(buf, TLVDefault, id) 189 | 190 | for _, url := range relays { 191 | writeTLVEntry(buf, TLVRelay, []byte(url)) 192 | } 193 | 194 | if pubkey, _ := hex.DecodeString(author); len(pubkey) == 32 { 195 | writeTLVEntry(buf, TLVAuthor, pubkey) 196 | } 197 | 198 | bits5, err := bech32.ConvertBits(buf.Bytes(), 8, 5, true) 199 | if err != nil { 200 | return "", fmt.Errorf("failed to convert bits: %w", err) 201 | } 202 | 203 | return bech32.Encode("nevent", bits5) 204 | } 205 | 206 | func EncodeEntity(publicKey string, kind int, identifier string, relays []string) (string, error) { 207 | buf := &bytes.Buffer{} 208 | 209 | writeTLVEntry(buf, TLVDefault, []byte(identifier)) 210 | 211 | for _, url := range relays { 212 | writeTLVEntry(buf, TLVRelay, []byte(url)) 213 | } 214 | 215 | pubkey, err := hex.DecodeString(publicKey) 216 | if err != nil { 217 | return "", fmt.Errorf("invalid pubkey '%s': %w", pubkey, err) 218 | } 219 | writeTLVEntry(buf, TLVAuthor, pubkey) 220 | 221 | kindBytes := make([]byte, 4) 222 | binary.BigEndian.PutUint32(kindBytes, uint32(kind)) 223 | writeTLVEntry(buf, TLVKind, kindBytes) 224 | 225 | bits5, err := bech32.ConvertBits(buf.Bytes(), 8, 5, true) 226 | if err != nil { 227 | return "", fmt.Errorf("failed to convert bits: %w", err) 228 | } 229 | 230 | return bech32.Encode("naddr", bits5) 231 | } 232 | -------------------------------------------------------------------------------- /pkg/ishell/example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "nrat/pkg/ishell" 11 | 12 | "github.com/fatih/color" 13 | ) 14 | 15 | func main() { 16 | shell := ishell.New() 17 | 18 | // display info. 19 | shell.Println("Sample Interactive Shell") 20 | 21 | // Consider the unicode characters supported by the users font 22 | // shell.SetMultiChoicePrompt(" >>"," - ") 23 | // shell.SetChecklistOptions("[ ] ","[X] ") 24 | 25 | // handle login. 26 | shell.AddCmd(&ishell.Cmd{ 27 | Name: "login", 28 | Func: func(c *ishell.Context) { 29 | c.ShowPrompt(false) 30 | defer c.ShowPrompt(true) 31 | 32 | c.Println("Let's simulate login") 33 | 34 | // prompt for input 35 | c.Print("Username: ") 36 | username := c.ReadLine() 37 | c.Print("Password: ") 38 | password := c.ReadPassword() 39 | 40 | // do something with username and password 41 | c.Println("Your inputs were", username, "and", password+".") 42 | }, 43 | Help: "simulate a login", 44 | }) 45 | 46 | // handle "greet". 47 | shell.AddCmd(&ishell.Cmd{ 48 | Name: "greet", 49 | Aliases: []string{"hello", "welcome"}, 50 | Help: "greet user", 51 | Func: func(c *ishell.Context) { 52 | name := "Stranger" 53 | if len(c.Args) > 0 { 54 | name = strings.Join(c.Args, " ") 55 | } 56 | c.Println("Hello", name) 57 | }, 58 | }) 59 | 60 | // handle "default". 61 | shell.AddCmd(&ishell.Cmd{ 62 | Name: "default", 63 | Help: "readline with default input", 64 | Func: func(c *ishell.Context) { 65 | c.ShowPrompt(false) 66 | defer c.ShowPrompt(true) 67 | 68 | defaultInput := "default input, you can edit this" 69 | if len(c.Args) > 0 { 70 | defaultInput = strings.Join(c.Args, " ") 71 | } 72 | 73 | c.Print("input: ") 74 | read := c.ReadLineWithDefault(defaultInput) 75 | 76 | if read == defaultInput { 77 | c.Println("you left the default input intact") 78 | } else { 79 | c.Printf("you modified input to '%s'", read) 80 | c.Println() 81 | } 82 | }, 83 | }) 84 | // read multiple lines with "multi" command 85 | shell.AddCmd(&ishell.Cmd{ 86 | Name: "multi", 87 | Help: "input in multiple lines", 88 | Func: func(c *ishell.Context) { 89 | c.Println("Input multiple lines and end with semicolon ';'.") 90 | lines := c.ReadMultiLines(";") 91 | c.Println("Done reading. You wrote:") 92 | c.Println(lines) 93 | }, 94 | }) 95 | 96 | // multiple choice 97 | shell.AddCmd(&ishell.Cmd{ 98 | Name: "choice", 99 | Help: "multiple choice prompt", 100 | Func: func(c *ishell.Context) { 101 | choice := c.MultiChoice([]string{ 102 | "Golangers", 103 | "Go programmers", 104 | "Gophers", 105 | "Goers", 106 | }, "What are Go programmers called ?") 107 | if choice == 2 { 108 | c.Println("You got it!") 109 | } else { 110 | c.Println("Sorry, you're wrong.") 111 | } 112 | }, 113 | }) 114 | 115 | // multiple choice 116 | shell.AddCmd(&ishell.Cmd{ 117 | Name: "checklist", 118 | Help: "checklist prompt", 119 | Func: func(c *ishell.Context) { 120 | languages := []string{"Python", "Go", "Haskell", "Rust"} 121 | choices := c.Checklist(languages, 122 | "What are your favourite programming languages ?", 123 | nil) 124 | out := func() (c []string) { 125 | for _, v := range choices { 126 | c = append(c, languages[v]) 127 | } 128 | return 129 | } 130 | c.Println("Your choices are", strings.Join(out(), ", ")) 131 | }, 132 | }) 133 | 134 | // progress bars 135 | { 136 | // determinate 137 | shell.AddCmd(&ishell.Cmd{ 138 | Name: "det", 139 | Help: "determinate progress bar", 140 | Func: func(c *ishell.Context) { 141 | c.ProgressBar().Start() 142 | for i := 0; i < 101; i++ { 143 | c.ProgressBar().Suffix(fmt.Sprint(" ", i, "%")) 144 | c.ProgressBar().Progress(i) 145 | time.Sleep(time.Millisecond * 100) 146 | } 147 | c.ProgressBar().Stop() 148 | }, 149 | }) 150 | 151 | // indeterminate 152 | shell.AddCmd(&ishell.Cmd{ 153 | Name: "ind", 154 | Help: "indeterminate progress bar", 155 | Func: func(c *ishell.Context) { 156 | c.ProgressBar().Indeterminate(true) 157 | c.ProgressBar().Start() 158 | time.Sleep(time.Second * 10) 159 | c.ProgressBar().Stop() 160 | }, 161 | }) 162 | } 163 | 164 | // subcommands and custom autocomplete. 165 | { 166 | var words []string 167 | autoCmd := &ishell.Cmd{ 168 | Name: "suggest", 169 | Help: "try auto complete", 170 | LongHelp: `Try dynamic autocomplete by adding and removing words. 171 | Then view the autocomplete by tabbing after "words" subcommand. 172 | 173 | This is an example of a long help.`, 174 | } 175 | autoCmd.AddCmd(&ishell.Cmd{ 176 | Name: "add", 177 | Help: "add words to autocomplete", 178 | Func: func(c *ishell.Context) { 179 | if len(c.Args) == 0 { 180 | c.Err(errors.New("missing word(s)")) 181 | return 182 | } 183 | words = append(words, c.Args...) 184 | }, 185 | }) 186 | 187 | autoCmd.AddCmd(&ishell.Cmd{ 188 | Name: "clear", 189 | Help: "clear words in autocomplete", 190 | Func: func(c *ishell.Context) { 191 | words = nil 192 | }, 193 | }) 194 | 195 | autoCmd.AddCmd(&ishell.Cmd{ 196 | Name: "words", 197 | Help: "add words with 'suggest add', then tab after typing 'suggest words '", 198 | Completer: func([]string) []string { 199 | return words 200 | }, 201 | }) 202 | 203 | shell.AddCmd(autoCmd) 204 | } 205 | 206 | shell.AddCmd(&ishell.Cmd{ 207 | Name: "paged", 208 | Help: "show paged text", 209 | Func: func(c *ishell.Context) { 210 | lines := "" 211 | line := `%d. This is a paged text input. 212 | This is another line of it. 213 | 214 | ` 215 | for i := 0; i < 100; i++ { 216 | lines += fmt.Sprintf(line, i+1) 217 | } 218 | c.ShowPaged(lines) 219 | }, 220 | }) 221 | 222 | cyan := color.New(color.FgCyan).SprintFunc() 223 | yellow := color.New(color.FgYellow).SprintFunc() 224 | boldRed := color.New(color.FgRed, color.Bold).SprintFunc() 225 | shell.AddCmd(&ishell.Cmd{ 226 | Name: "color", 227 | Help: "color print", 228 | Func: func(c *ishell.Context) { 229 | c.Print(cyan("cyan\n")) 230 | c.Println(yellow("yellow")) 231 | c.Printf("%s\n", boldRed("bold red")) 232 | }, 233 | }) 234 | 235 | // when started with "exit" as first argument, assume non-interactive execution 236 | if len(os.Args) > 1 && os.Args[1] == "exit" { 237 | shell.Process(os.Args[2:]...) 238 | } else { 239 | // start shell 240 | shell.Run() 241 | // teardown 242 | shell.Close() 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /pkg/nostr/event_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestEventParsingAndVerifying(t *testing.T) { 9 | rawEvents := []string{ 10 | `{"id":"dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1644271588,"kind":1,"tags":[],"content":"now that https://blueskyweb.org/blog/2-7-2022-overview was announced we can stop working on nostr?","sig":"230e9d8f0ddaf7eb70b5f7741ccfa37e87a455c9a469282e3464e2052d3192cd63a167e196e381ef9d7e69e9ea43af2443b839974dc85d8aaab9efe1d9296524"}`, 11 | `{"id":"dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1644271588,"kind":1,"tags":[],"content":"now that https://blueskyweb.org/blog/2-7-2022-overview was announced we can stop working on nostr?","sig":"230e9d8f0ddaf7eb70b5f7741ccfa37e87a455c9a469282e3464e2052d3192cd63a167e196e381ef9d7e69e9ea43af2443b839974dc85d8aaab9efe1d9296524","extrakey":55}`, 12 | `{"id":"dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1644271588,"kind":1,"tags":[],"content":"now that https://blueskyweb.org/blog/2-7-2022-overview was announced we can stop working on nostr?","sig":"230e9d8f0ddaf7eb70b5f7741ccfa37e87a455c9a469282e3464e2052d3192cd63a167e196e381ef9d7e69e9ea43af2443b839974dc85d8aaab9efe1d9296524","extrakey":"aaa"}`, 13 | `{"id":"9e662bdd7d8abc40b5b15ee1ff5e9320efc87e9274d8d440c58e6eed2dddfbe2","pubkey":"373ebe3d45ec91977296a178d9f19f326c70631d2a1b0bbba5c5ecc2eb53b9e7","created_at":1644844224,"kind":3,"tags":[["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","75fc5ac2487363293bd27fb0d14fb966477d0f1dbc6361d37806a6a740eda91e"],["p","46d0dfd3a724a302ca9175163bdf788f3606b3fd1bb12d5fe055d1e418cb60ea"]],"content":"{\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social\":{\"read\":false,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relay.bitid.nz\":{\"read\":true,\"write\":true},\"wss://nostr.rocks\":{\"read\":true,\"write\":true}}","sig":"811355d3484d375df47581cb5d66bed05002c2978894098304f20b595e571b7e01b2efd906c5650080ffe49cf1c62b36715698e9d88b9e8be43029a2f3fa66be"}`, 14 | } 15 | 16 | for _, raw := range rawEvents { 17 | var ev Event 18 | err := json.Unmarshal([]byte(raw), &ev) 19 | if err != nil { 20 | t.Errorf("failed to parse event json: %v", err) 21 | } 22 | 23 | if ev.GetID() != ev.ID { 24 | t.Errorf("error serializing event id: %s != %s", ev.GetID(), ev.ID) 25 | } 26 | 27 | if ok, _ := ev.CheckSignature(); !ok { 28 | t.Error("signature verification failed when it should have succeeded") 29 | } 30 | 31 | asjson, err := json.Marshal(ev) 32 | if err != nil { 33 | t.Errorf("failed to re marshal event as json: %v", err) 34 | } 35 | 36 | if string(asjson) != raw { 37 | t.Log(string(asjson)) 38 | t.Error("json serialization broken") 39 | } 40 | } 41 | } 42 | 43 | func TestEventSerialization(t *testing.T) { 44 | events := []Event{ 45 | { 46 | ID: "92570b321da503eac8014b23447301eb3d0bbdfbace0d11a4e4072e72bb7205d", 47 | PubKey: "e9142f724955c5854de36324dab0434f97b15ec6b33464d56ebe491e3f559d1b", 48 | Kind: 4, 49 | CreatedAt: Timestamp(1671028682), 50 | Tags: Tags{Tag{"p", "f8340b2bde651576b75af61aa26c80e13c65029f00f7f64004eece679bf7059f"}}, 51 | Content: "you say yes, I say no", 52 | Sig: "ed08d2dd5b0f7b6a3cdc74643d4adee3158ddede9cc848e8cd97630c097001acc2d052d2d3ec2b7ac4708b2314b797106d1b3c107322e61b5e5cc2116e099b79", 53 | }, 54 | } 55 | 56 | for _, evt := range events { 57 | b, err := json.Marshal(evt) 58 | if err != nil { 59 | t.Log(evt) 60 | t.Error("failed to serialize this event") 61 | } 62 | 63 | var re Event 64 | if err := json.Unmarshal(b, &re); err != nil { 65 | t.Log(string(b)) 66 | t.Error("failed to re parse event just serialized") 67 | } 68 | 69 | if evt.ID != re.ID || evt.PubKey != re.PubKey || evt.Content != re.Content || 70 | evt.CreatedAt != re.CreatedAt || evt.Sig != re.Sig || 71 | len(evt.Tags) != len(re.Tags) { 72 | t.Error("reparsed event differs from original") 73 | } 74 | 75 | for i := range evt.Tags { 76 | if len(evt.Tags[i]) != len(re.Tags[i]) { 77 | t.Errorf("reparsed tags %d length differ from original", i) 78 | continue 79 | } 80 | 81 | for j := range evt.Tags[i] { 82 | if evt.Tags[i][j] != re.Tags[i][j] { 83 | t.Errorf("reparsed tag content %d %d length differ from original", i, j) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | func TestEventSerializationWithExtraFields(t *testing.T) { 91 | evt := Event{ 92 | ID: "92570b321da503eac8014b23447301eb3d0bbdfbace0d11a4e4072e72bb7205d", 93 | PubKey: "e9142f724955c5854de36324dab0434f97b15ec6b33464d56ebe491e3f559d1b", 94 | Kind: 7, 95 | CreatedAt: Timestamp(1671028682), 96 | Content: "there is an extra field here", 97 | Sig: "ed08d2dd5b0f7b6a3cdc74643d4adee3158ddede9cc848e8cd97630c097001acc2d052d2d3ec2b7ac4708b2314b797106d1b3c107322e61b5e5cc2116e099b79", 98 | } 99 | evt.SetExtra("glub", true) 100 | evt.SetExtra("plik", nil) 101 | evt.SetExtra("elet", 77) 102 | evt.SetExtra("malf", "hello") 103 | 104 | b, err := json.Marshal(evt) 105 | if err != nil { 106 | t.Log(evt) 107 | t.Error("failed to serialize this event") 108 | } 109 | 110 | var re Event 111 | if err := json.Unmarshal(b, &re); err != nil { 112 | t.Log(string(b)) 113 | t.Error("failed to re parse event just serialized") 114 | } 115 | 116 | if evt.ID != re.ID || evt.PubKey != re.PubKey || evt.Content != re.Content || 117 | evt.CreatedAt != re.CreatedAt || evt.Sig != re.Sig || 118 | len(evt.Tags) != len(re.Tags) { 119 | t.Error("reparsed event differs from original") 120 | } 121 | 122 | if evt.GetExtra("malf").(string) != evt.GetExtraString("malf") || evt.GetExtraString("malf") != "hello" { 123 | t.Errorf("failed to parse extra string") 124 | } 125 | 126 | if float64(evt.GetExtra("elet").(int)) != evt.GetExtraNumber("elet") || evt.GetExtraNumber("elet") != 77 { 127 | t.Logf("number: %v == %v", evt.GetExtra("elet"), evt.GetExtraNumber("elet")) 128 | t.Errorf("failed to parse extra number") 129 | } 130 | 131 | if evt.GetExtra("glub").(bool) != evt.GetExtraBoolean("glub") || evt.GetExtraBoolean("glub") != true { 132 | t.Errorf("failed to parse extra boolean") 133 | } 134 | 135 | if evt.GetExtra("plik") != nil { 136 | t.Errorf("failed to parse extra null") 137 | } 138 | } 139 | 140 | // func mustSignEvent(t *testing.T, privkey string, event *Event) { 141 | // t.Helper() 142 | // if err := event.Sign(privkey); err != nil { 143 | // t.Fatalf("event.Sign: %v", err) 144 | // } 145 | // } 146 | -------------------------------------------------------------------------------- /cmd/agent/internal/agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | "uw/uboot" 10 | "uw/ulog" 11 | "uw/umap" 12 | 13 | "nrat/pkg/nostr" 14 | "nrat/pkg/nostr/nip04" 15 | 16 | "nrat/model" 17 | "nrat/utils" 18 | ) 19 | 20 | var idCacheExpire = 10 * time.Minute 21 | 22 | func AgentUint(c *uboot.Context) (e error) { 23 | if e := c.Require(c.Context(), "unostr"); e != nil { 24 | return e 25 | } 26 | if e := c.Require(c.Context(), "storage"); e != nil { 27 | return e 28 | } 29 | 30 | storage, ok := utils.UbootGetAssert[model.Storage[*model.AgentStorageData]](c, "storage") 31 | if !ok { 32 | return errors.New("get storage failed") 33 | } 34 | unostr, ok := utils.UbootGetAssert[model.Unostr](c, "unostr") 35 | if !ok { 36 | return errors.New("get unostr failed") 37 | } 38 | shareKey, e := nip04.ComputeSharedSecret(storage.Storage().PublicKey, 39 | storage.Storage().PrivateKey) 40 | if e != nil { 41 | return fmt.Errorf("compute shared secret failed: %w", e) 42 | } 43 | 44 | agent := &Agent{ 45 | unostr: unostr, 46 | eventCh: make(chan *model.Event, 16), 47 | selfShareKey: shareKey, 48 | eventIdCache: umap.NewCache[string, bool](time.Second * 60), 49 | storage: storage, 50 | } 51 | 52 | // 启动时广播自己 53 | if e := agent.broadcastSelf(c.Context()); e != nil { 54 | return fmt.Errorf("broadcast self: %w", e) 55 | } 56 | 57 | broadcastInterval, e := time.ParseDuration(storage.Storage().BroadcastInterval) 58 | if e != nil || broadcastInterval < 1 { 59 | ulog.Warn("parse broadcast interval failed or broadcast interval < 1, use default 10m") 60 | broadcastInterval = 10 * time.Minute 61 | } 62 | 63 | // 定期广播自己 64 | go agent.broadcastSelfLoop(broadcastInterval) 65 | 66 | go agent.eventHandler() 67 | if e := agent.subscribe(); e != nil { 68 | return fmt.Errorf("subscribe failed: %w", e) 69 | } 70 | 71 | agent.unostr.SetConnectEvent(func() { 72 | if e := agent.subscribe(); e != nil { 73 | ulog.Warn("subscribe failed: %s", e) 74 | } 75 | }) 76 | 77 | return nil 78 | } 79 | 80 | type Agent struct { 81 | unostr model.Unostr 82 | broadcastTicker *time.Ticker 83 | selfShareKey []byte 84 | eventCh chan *model.Event 85 | eventUnSub func() 86 | eventIdCache *umap.Cache[string, bool] 87 | storage model.Storage[*model.AgentStorageData] 88 | } 89 | 90 | func (agent *Agent) broadcastSelfLoop(broadcastInterval time.Duration) { 91 | if agent.broadcastTicker != nil { 92 | agent.broadcastTicker.Stop() 93 | agent.broadcastTicker.Reset(broadcastInterval) 94 | return 95 | } 96 | 97 | agent.broadcastTicker = time.NewTicker(broadcastInterval) 98 | for range agent.broadcastTicker.C { 99 | if e := agent.broadcastSelf(context.Background()); e != nil { 100 | ulog.Error("broadcast self ticker: %s", e) 101 | } 102 | } 103 | } 104 | 105 | func (agent *Agent) broadcastSelf(ctx context.Context) error { 106 | ev := nostr.Event{ 107 | PubKey: agent.storage.Storage().PublicKey, 108 | CreatedAt: nostr.Now(), 109 | Kind: nostr.KindSetMetadata, 110 | Tags: nostr.Tags{ 111 | {"p", agent.storage.Storage().PublicKey}, 112 | }, 113 | Content: "nrat", 114 | } 115 | 116 | if e := ev.Sign(agent.storage.Storage().PrivateKey); e != nil { 117 | return fmt.Errorf("sign failed: %w", e) 118 | } 119 | 120 | status, e := agent.unostr.Relay().Publish(ctx, ev) 121 | if e != nil { 122 | return fmt.Errorf("publish failed: %w", e) 123 | } 124 | 125 | ulog.Debug("publish self status: %s", status) 126 | 127 | if status < 0 { 128 | return fmt.Errorf("publish failed: %s", status) 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (agent *Agent) subscribe() error { 135 | if agent.eventUnSub != nil { 136 | agent.eventUnSub() 137 | } 138 | 139 | now := nostr.Now() 140 | sub, e := agent.unostr.Relay().Subscribe(context.Background(), []nostr.Filter{{ 141 | Kinds: []int{nostr.KindApplicationSpecificData}, 142 | Authors: []string{agent.storage.Storage().PublicKey}, 143 | Tags: nostr.TagMap{"d": []string{"control"}}, 144 | Since: &now, 145 | }}) 146 | if e != nil { 147 | return fmt.Errorf("subscribe failed: %w", e) 148 | } 149 | 150 | agent.eventUnSub = sub.Unsub 151 | agent.subscribeRange(sub.Events) 152 | return nil 153 | } 154 | 155 | func (agent *Agent) subscribeRange(ch chan *nostr.Event) { 156 | for ev := range ch { 157 | if t, e := ev.CheckSignature(); e != nil { 158 | ulog.Warn("check signature failed: %s", e) 159 | } else if !t { 160 | ulog.Warn("signature not match") 161 | } 162 | 163 | if agent.eventIdCache.Get(ev.ID) { 164 | ulog.Debug("event id %s already handled", ev.ID) 165 | continue 166 | } 167 | agent.eventIdCache.Set(ev.ID, true, idCacheExpire) 168 | 169 | if ev.CreatedAt.Time().Add(idCacheExpire).Before(time.Now()) { 170 | ulog.Debug("event id %s expired", ev.ID) 171 | } 172 | 173 | message, e := nip04.Decrypt(ev.Content, agent.selfShareKey) 174 | if e != nil { 175 | ulog.Warn("decrypt event failed: %s", e) 176 | continue 177 | } 178 | 179 | evt := &model.Event{ 180 | Id: ev.ID, 181 | } 182 | 183 | if e := evt.Decode(message); e != nil { 184 | ulog.Warn("decode event failed: %s", e) 185 | continue 186 | } 187 | 188 | evt.Content = strings.TrimSpace(evt.Content) 189 | agent.eventCh <- evt 190 | } 191 | } 192 | 193 | func (agent *Agent) publish(ctx context.Context, evt *model.Event) error { 194 | encMessage, e := nip04.Encrypt(evt.Encode(), agent.selfShareKey) 195 | if e != nil { 196 | return fmt.Errorf("encrypt failed: %w", e) 197 | } 198 | 199 | ev := nostr.Event{ 200 | PubKey: agent.storage.Storage().PublicKey, 201 | CreatedAt: nostr.Now(), 202 | Kind: nostr.KindApplicationSpecificData, 203 | Tags: nostr.Tags{{"d", "agent"}}, 204 | Content: encMessage, 205 | } 206 | 207 | if e := ev.Sign(agent.storage.Storage().PrivateKey); e != nil { 208 | fmt.Printf("failed to sign: %s\n", e) 209 | } 210 | 211 | ret, e := agent.unostr.Relay().Publish(ctx, ev) 212 | if e != nil { 213 | return fmt.Errorf("publish failed: %w", e) 214 | } 215 | 216 | if ret < 0 { 217 | return fmt.Errorf("publish failed: %s", ret) 218 | } 219 | 220 | return nil 221 | } 222 | 223 | func (agent *Agent) eventHandler() { 224 | for ev := range agent.eventCh { 225 | if h, ok := agentHandlers[ev.Type]; ok && h != nil { 226 | if ret, e := h(agent, ev); ret != "" || e != nil { 227 | evt := &model.Event{ 228 | Type: ev.Type, 229 | Content: ret, 230 | } 231 | 232 | if e != nil { 233 | evt.Error = e.Error() 234 | ulog.Warn("handle %s event failed: %s", ev.Type, e) 235 | } 236 | 237 | ctx, cancel := context.WithTimeout(context.Background(), 238 | agent.unostr.ConnectTimeout()) 239 | if e := agent.publish(ctx, evt); e != nil { 240 | ulog.Warn("handle event failed: %s", e) 241 | } 242 | cancel() 243 | } 244 | continue 245 | } 246 | 247 | ulog.Warn("unknown event type: %s", ev.Type) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /pkg/nostr/nip19/nip19_test.go: -------------------------------------------------------------------------------- 1 | package nip19 2 | 3 | import ( 4 | "testing" 5 | 6 | "nrat/pkg/nostr" 7 | ) 8 | 9 | func TestEncodeNpub(t *testing.T) { 10 | npub, err := EncodePublicKey("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d") 11 | if err != nil { 12 | t.Errorf("shouldn't error: %s", err) 13 | } 14 | if npub != "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" { 15 | t.Error("produced an unexpected npub string") 16 | } 17 | } 18 | 19 | func TestEncodeNsec(t *testing.T) { 20 | nsec, err := EncodePrivateKey("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d") 21 | if err != nil { 22 | t.Errorf("shouldn't error: %s", err) 23 | } 24 | if nsec != "nsec180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsgyumg0" { 25 | t.Error("produced an unexpected nsec string") 26 | } 27 | } 28 | 29 | func TestDecodeNpub(t *testing.T) { 30 | prefix, pubkey, err := Decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6") 31 | if err != nil { 32 | t.Errorf("shouldn't error: %s", err) 33 | } 34 | if prefix != "npub" { 35 | t.Error("returned invalid prefix") 36 | } 37 | if pubkey.(string) != "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" { 38 | t.Error("returned wrong pubkey") 39 | } 40 | } 41 | 42 | func TestFailDecodeBadChecksumNpub(t *testing.T) { 43 | _, _, err := Decode("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w4") 44 | if err == nil { 45 | t.Errorf("should have errored: %s", err) 46 | } 47 | } 48 | 49 | func TestDecodeNprofile(t *testing.T) { 50 | prefix, data, err := Decode("nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p") 51 | if err != nil { 52 | t.Error("failed to decode nprofile") 53 | } 54 | if prefix != "nprofile" { 55 | t.Error("what") 56 | } 57 | pp, ok := data.(nostr.ProfilePointer) 58 | if !ok { 59 | t.Error("value returned of wrong type") 60 | } 61 | 62 | if pp.PublicKey != "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" { 63 | t.Error("decoded invalid public key") 64 | } 65 | 66 | if len(pp.Relays) != 2 { 67 | t.Error("decoded wrong number of relays") 68 | } 69 | if pp.Relays[0] != "wss://r.x.com" || pp.Relays[1] != "wss://djbas.sadkb.com" { 70 | t.Error("decoded relay URLs wrongly") 71 | } 72 | } 73 | 74 | func TestDecodeOtherNprofile(t *testing.T) { 75 | prefix, data, err := Decode("nprofile1qqsw3dy8cpumpanud9dwd3xz254y0uu2m739x0x9jf4a9sgzjshaedcpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5qyw8wumn8ghj7mn0wd68yttjv4kxz7fww4h8get5dpezumt9qyvhwumn8ghj7un9d3shjetj9enxjct5dfskvtnrdakstl69hg") 76 | if err != nil { 77 | t.Error("failed to decode nprofile") 78 | } 79 | if prefix != "nprofile" { 80 | t.Error("what") 81 | } 82 | pp, ok := data.(nostr.ProfilePointer) 83 | if !ok { 84 | t.Error("value returned of wrong type") 85 | } 86 | 87 | if pp.PublicKey != "e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7" { 88 | t.Error("decoded invalid public key") 89 | } 90 | 91 | if len(pp.Relays) != 3 { 92 | t.Error("decoded wrong number of relays") 93 | } 94 | if pp.Relays[0] != "wss://nostr-pub.wellorder.net" || pp.Relays[1] != "wss://nostr-relay.untethr.me" { 95 | t.Error("decoded relay URLs wrongly") 96 | } 97 | } 98 | 99 | func TestEncodeNprofile(t *testing.T) { 100 | nprofile, err := EncodeProfile("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", []string{ 101 | "wss://r.x.com", 102 | "wss://djbas.sadkb.com", 103 | }) 104 | if err != nil { 105 | t.Errorf("shouldn't error: %s", err) 106 | } 107 | if nprofile != "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" { 108 | t.Error("produced an unexpected nprofile string") 109 | } 110 | } 111 | 112 | func TestEncodeDecodeNaddr(t *testing.T) { 113 | naddr, err := EncodeEntity( 114 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 115 | 30023, 116 | "banana", 117 | []string{ 118 | "wss://relay.nostr.example.mydomain.example.com", 119 | "wss://nostr.banana.com", 120 | }) 121 | if err != nil { 122 | t.Errorf("shouldn't error: %s", err) 123 | } 124 | if naddr != "naddr1qqrxyctwv9hxzqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmdqgsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8grqsqqqa28a3lkds" { 125 | t.Errorf("produced an unexpected naddr string: %s", naddr) 126 | } 127 | 128 | prefix, data, err := Decode(naddr) 129 | if err != nil { 130 | t.Errorf("shouldn't error: %s", err) 131 | } 132 | if prefix != "naddr" { 133 | t.Error("returned invalid prefix") 134 | } 135 | ep := data.(nostr.EntityPointer) 136 | if ep.PublicKey != "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" { 137 | t.Error("returned wrong pubkey") 138 | } 139 | if ep.Kind != 30023 { 140 | t.Error("returned wrong kind") 141 | } 142 | if ep.Identifier != "banana" { 143 | t.Error("returned wrong identifier") 144 | } 145 | if ep.Relays[0] != "wss://relay.nostr.example.mydomain.example.com" || ep.Relays[1] != "wss://nostr.banana.com" { 146 | t.Error("returned wrong relays") 147 | } 148 | } 149 | 150 | func TestDecodeNaddrWithoutRelays(t *testing.T) { 151 | prefix, data, err := Decode("naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5") 152 | if err != nil { 153 | t.Errorf("shouldn't error: %s", err) 154 | } 155 | if prefix != "naddr" { 156 | t.Error("returned invalid prefix") 157 | } 158 | ep := data.(nostr.EntityPointer) 159 | if ep.PublicKey != "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" { 160 | t.Error("returned wrong pubkey") 161 | } 162 | if ep.Kind != 30023 { 163 | t.Error("returned wrong kind") 164 | } 165 | if ep.Identifier != "references" { 166 | t.Error("returned wrong identifier") 167 | } 168 | if len(ep.Relays) != 0 { 169 | t.Error("relays should have been an empty array") 170 | } 171 | } 172 | 173 | func TestEncodeDecodeNEventTestEncodeDecodeNEvent(t *testing.T) { 174 | nevent, err := EncodeEvent( 175 | "45326f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", 176 | []string{"wss://banana.com"}, 177 | "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751abb88", 178 | ) 179 | if err != nil { 180 | t.Errorf("shouldn't error: %s", err) 181 | } 182 | 183 | prefix, res, err := Decode(nevent) 184 | if err != nil { 185 | t.Errorf("shouldn't error: %s", err) 186 | } 187 | 188 | if prefix != "nevent" { 189 | t.Errorf("should have 'nevent' prefix, not '%s'", prefix) 190 | } 191 | 192 | ep, ok := res.(nostr.EventPointer) 193 | if !ok { 194 | t.Errorf("'%s' should be an nevent, not %v", nevent, res) 195 | } 196 | 197 | if ep.Author != "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751abb88" { 198 | t.Error("wrong author") 199 | } 200 | 201 | if ep.ID != "45326f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" { 202 | t.Error("wrong id") 203 | } 204 | 205 | if len(ep.Relays) != 1 || ep.Relays[0] != "wss://banana.com" { 206 | t.Error("wrong relay") 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /pkg/nostr/envelopes.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/mailru/easyjson" 9 | jwriter "github.com/mailru/easyjson/jwriter" 10 | "github.com/tidwall/gjson" 11 | ) 12 | 13 | func ParseMessage(message []byte) Envelope { 14 | firstComma := bytes.Index(message, []byte{','}) 15 | if firstComma == -1 { 16 | return nil 17 | } 18 | label := message[0:firstComma] 19 | var v Envelope 20 | switch { 21 | case bytes.Contains(label, []byte("EVENT")): 22 | v = &EventEnvelope{} 23 | case bytes.Contains(label, []byte("REQ")): 24 | v = &ReqEnvelope{} 25 | case bytes.Contains(label, []byte("NOTICE")): 26 | x := NoticeEnvelope("") 27 | v = &x 28 | case bytes.Contains(label, []byte("EOSE")): 29 | x := EOSEEnvelope("") 30 | v = &x 31 | case bytes.Contains(label, []byte("OK")): 32 | v = &OKEnvelope{} 33 | case bytes.Contains(label, []byte("AUTH")): 34 | v = &AuthEnvelope{} 35 | case bytes.Contains(label, []byte("CLOSE")): 36 | x := CloseEnvelope("") 37 | v = &x 38 | } 39 | 40 | if err := v.UnmarshalJSON(message); err != nil { 41 | return nil 42 | } 43 | return v 44 | } 45 | 46 | type Envelope interface { 47 | Label() string 48 | UnmarshalJSON([]byte) error 49 | MarshalJSON() ([]byte, error) 50 | } 51 | 52 | type EventEnvelope struct { 53 | SubscriptionID *string 54 | Event 55 | } 56 | 57 | var ( 58 | _ Envelope = (*EventEnvelope)(nil) 59 | _ Envelope = (*ReqEnvelope)(nil) 60 | _ Envelope = (*NoticeEnvelope)(nil) 61 | _ Envelope = (*EOSEEnvelope)(nil) 62 | _ Envelope = (*CloseEnvelope)(nil) 63 | _ Envelope = (*OKEnvelope)(nil) 64 | _ Envelope = (*AuthEnvelope)(nil) 65 | ) 66 | 67 | func (EventEnvelope) Label() string { return "EVENT" } 68 | 69 | func (v *EventEnvelope) UnmarshalJSON(data []byte) error { 70 | r := gjson.ParseBytes(data) 71 | arr := r.Array() 72 | switch len(arr) { 73 | case 2: 74 | return easyjson.Unmarshal([]byte(arr[1].Raw), &v.Event) 75 | case 3: 76 | v.SubscriptionID = &arr[1].Str 77 | return easyjson.Unmarshal([]byte(arr[2].Raw), &v.Event) 78 | default: 79 | return fmt.Errorf("failed to decode EVENT envelope") 80 | } 81 | } 82 | 83 | func (v EventEnvelope) MarshalJSON() ([]byte, error) { 84 | w := jwriter.Writer{} 85 | w.RawString(`["EVENT",`) 86 | if v.SubscriptionID != nil { 87 | w.RawString(`"` + *v.SubscriptionID + `",`) 88 | } 89 | v.MarshalEasyJSON(&w) 90 | w.RawString(`]`) 91 | return w.BuildBytes() 92 | } 93 | 94 | type ReqEnvelope struct { 95 | SubscriptionID string 96 | Filters 97 | } 98 | 99 | func (ReqEnvelope) Label() string { return "REQ" } 100 | 101 | func (v *ReqEnvelope) UnmarshalJSON(data []byte) error { 102 | r := gjson.ParseBytes(data) 103 | arr := r.Array() 104 | if len(arr) < 3 { 105 | return fmt.Errorf("failed to decode REQ envelope: missing filters") 106 | } 107 | v.SubscriptionID = arr[1].Str 108 | v.Filters = make(Filters, len(arr)-2) 109 | f := 0 110 | for i := 2; i < len(arr); i++ { 111 | if err := easyjson.Unmarshal([]byte(arr[i].Raw), &v.Filters[f]); err != nil { 112 | return fmt.Errorf("%w -- on filter %d", err, f) 113 | } 114 | f++ 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func (v ReqEnvelope) MarshalJSON() ([]byte, error) { 121 | w := jwriter.Writer{} 122 | w.RawString(`["REQ",`) 123 | w.RawString(`"` + v.SubscriptionID + `"`) 124 | for _, filter := range v.Filters { 125 | w.RawString(`,`) 126 | filter.MarshalEasyJSON(&w) 127 | } 128 | w.RawString(`]`) 129 | return w.BuildBytes() 130 | } 131 | 132 | type NoticeEnvelope string 133 | 134 | func (NoticeEnvelope) Label() string { return "NOTICE" } 135 | 136 | func (v *NoticeEnvelope) UnmarshalJSON(data []byte) error { 137 | r := gjson.ParseBytes(data) 138 | arr := r.Array() 139 | switch len(arr) { 140 | case 2: 141 | *v = NoticeEnvelope(arr[1].Str) 142 | return nil 143 | default: 144 | return fmt.Errorf("failed to decode NOTICE envelope") 145 | } 146 | } 147 | 148 | func (v NoticeEnvelope) MarshalJSON() ([]byte, error) { 149 | w := jwriter.Writer{} 150 | w.RawString(`["NOTICE",`) 151 | w.Raw(json.Marshal(string(v))) 152 | w.RawString(`]`) 153 | return w.BuildBytes() 154 | } 155 | 156 | type EOSEEnvelope string 157 | 158 | func (EOSEEnvelope) Label() string { return "EOSE" } 159 | 160 | func (v *EOSEEnvelope) UnmarshalJSON(data []byte) error { 161 | r := gjson.ParseBytes(data) 162 | arr := r.Array() 163 | switch len(arr) { 164 | case 2: 165 | *v = EOSEEnvelope(arr[1].Str) 166 | return nil 167 | default: 168 | return fmt.Errorf("failed to decode EOSE envelope") 169 | } 170 | } 171 | 172 | func (v EOSEEnvelope) MarshalJSON() ([]byte, error) { 173 | w := jwriter.Writer{} 174 | w.RawString(`["EOSE",`) 175 | w.Raw(json.Marshal(string(v))) 176 | w.RawString(`]`) 177 | return w.BuildBytes() 178 | } 179 | 180 | type CloseEnvelope string 181 | 182 | func (CloseEnvelope) Label() string { return "CLOSE" } 183 | 184 | func (v *CloseEnvelope) UnmarshalJSON(data []byte) error { 185 | r := gjson.ParseBytes(data) 186 | arr := r.Array() 187 | switch len(arr) { 188 | case 2: 189 | *v = CloseEnvelope(arr[1].Str) 190 | return nil 191 | default: 192 | return fmt.Errorf("failed to decode CLOSE envelope") 193 | } 194 | } 195 | 196 | func (v CloseEnvelope) MarshalJSON() ([]byte, error) { 197 | w := jwriter.Writer{} 198 | w.RawString(`["CLOSE",`) 199 | w.Raw(json.Marshal(string(v))) 200 | w.RawString(`]`) 201 | return w.BuildBytes() 202 | } 203 | 204 | type OKEnvelope struct { 205 | EventID string 206 | OK bool 207 | Reason *string 208 | } 209 | 210 | func (OKEnvelope) Label() string { return "OK" } 211 | 212 | func (v *OKEnvelope) UnmarshalJSON(data []byte) error { 213 | r := gjson.ParseBytes(data) 214 | arr := r.Array() 215 | if len(arr) < 3 { 216 | return fmt.Errorf("failed to decode OK envelope: missing fields") 217 | } 218 | v.EventID = arr[1].Str 219 | v.OK = arr[2].Raw == "true" 220 | 221 | if len(arr) > 3 { 222 | v.Reason = &arr[3].Str 223 | } 224 | 225 | return nil 226 | } 227 | 228 | func (v OKEnvelope) MarshalJSON() ([]byte, error) { 229 | w := jwriter.Writer{} 230 | w.RawString(`["OK",`) 231 | w.RawString(`"` + v.EventID + `",`) 232 | ok := "false" 233 | if v.OK { 234 | ok = "true" 235 | } 236 | w.RawString(ok) 237 | if v.Reason != nil { 238 | w.RawString(`,`) 239 | w.Raw(json.Marshal(v.Reason)) 240 | } 241 | w.RawString(`]`) 242 | return w.BuildBytes() 243 | } 244 | 245 | type AuthEnvelope struct { 246 | Challenge *string 247 | Event Event 248 | } 249 | 250 | func (AuthEnvelope) Label() string { return "AUTH" } 251 | 252 | func (v *AuthEnvelope) UnmarshalJSON(data []byte) error { 253 | r := gjson.ParseBytes(data) 254 | arr := r.Array() 255 | if len(arr) < 2 { 256 | return fmt.Errorf("failed to decode Auth envelope: missing fields") 257 | } 258 | if arr[1].IsObject() { 259 | return easyjson.Unmarshal([]byte(arr[1].Raw), &v.Event) 260 | } else { 261 | v.Challenge = &arr[1].Str 262 | } 263 | return nil 264 | } 265 | 266 | func (v AuthEnvelope) MarshalJSON() ([]byte, error) { 267 | w := jwriter.Writer{} 268 | w.RawString(`["AUTH",`) 269 | if v.Challenge != nil { 270 | w.Raw(json.Marshal(*v.Challenge)) 271 | } else { 272 | v.Event.MarshalEasyJSON(&w) 273 | } 274 | w.RawString(`]`) 275 | return w.BuildBytes() 276 | } 277 | -------------------------------------------------------------------------------- /pkg/ishell/actions.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | // Actions are actions that can be performed by a shell. 12 | type Actions interface { 13 | // ReadLine reads a line from standard input. 14 | ReadLine() string 15 | // ReadLineErr is ReadLine but returns error as well 16 | ReadLineErr() (string, error) 17 | // ReadLineWithDefault reads a line from standard input with default value. 18 | ReadLineWithDefault(string) string 19 | // ReadPassword reads password from standard input without echoing the characters. 20 | // Note that this only works as expected when the standard input is a terminal. 21 | ReadPassword() string 22 | // ReadPasswordErr is ReadPassword but returns error as well 23 | ReadPasswordErr() (string, error) 24 | // ReadMultiLinesFunc reads multiple lines from standard input. It passes each read line to 25 | // f and stops reading when f returns false. 26 | ReadMultiLinesFunc(f func(string) bool) string 27 | // ReadMultiLines reads multiple lines from standard input. It stops reading when terminator 28 | // is encountered at the end of the line. It returns the lines read including terminator. 29 | // For more control, use ReadMultiLinesFunc. 30 | ReadMultiLines(terminator string) string 31 | // Println prints to output and ends with newline character. 32 | Println(val ...interface{}) 33 | // Print prints to output. 34 | Print(val ...interface{}) 35 | // Printf prints to output using string format. 36 | Printf(format string, val ...interface{}) 37 | // ShowPaged shows a paged text that is scrollable. 38 | // This leverages on "less" for unix and "more" for windows. 39 | ShowPaged(text string) error 40 | // ShowPagedReader shows a paged text that is scrollable, from a reader source. 41 | // This leverages on "less" for unix and "more" for windows. 42 | ShowPagedReader(r io.Reader) error 43 | // MultiChoice presents options to the user. 44 | // returns the index of the selection or -1 if nothing is 45 | // selected. 46 | // text is displayed before the options. 47 | MultiChoice(options []string, text string) int 48 | // Checklist is similar to MultiChoice but user can choose multiple variants using Space. 49 | // init is initially selected options. 50 | Checklist(options []string, text string, init []int) []int 51 | // SetPrompt sets the prompt string. The string to be displayed before the cursor. 52 | SetPrompt(prompt string) 53 | // SetMultiPrompt sets the prompt string used for multiple lines. The string to be displayed before 54 | // the cursor; starting from the second line of input. 55 | SetMultiPrompt(prompt string) 56 | // SetMultiChoicePrompt sets the prompt strings used for MultiChoice(). 57 | SetMultiChoicePrompt(prompt, spacer string) 58 | // SetChecklistOptions sets the strings representing the options of Checklist(). 59 | // The generated string depends on SetMultiChoicePrompt() also. 60 | SetChecklistOptions(open, selected string) 61 | // ShowPrompt sets whether prompt should show when requesting input for ReadLine and ReadPassword. 62 | // Defaults to true. 63 | ShowPrompt(show bool) 64 | // Cmds returns all the commands added to the shell. 65 | Cmds() []*Cmd 66 | // HelpText returns the computed help of top level commands. 67 | HelpText() string 68 | // ClearScreen clears the screen. Same behaviour as running 'clear' in unix terminal or 'cls' in windows cmd. 69 | ClearScreen() error 70 | // Stop stops the shell. This will stop the shell from auto reading inputs and calling 71 | // registered functions. A stopped shell is only inactive but totally functional. 72 | // Its functions can still be called and can be restarted. 73 | Stop() 74 | } 75 | 76 | type shellActionsImpl struct { 77 | *Shell 78 | } 79 | 80 | // ReadLine reads a line from standard input. 81 | func (s *shellActionsImpl) ReadLine() string { 82 | line, _ := s.readLine() 83 | return line 84 | } 85 | 86 | func (s *shellActionsImpl) ReadLineErr() (string, error) { 87 | return s.readLine() 88 | } 89 | 90 | func (s *shellActionsImpl) ReadLineWithDefault(defaultValue string) string { 91 | s.reader.defaultInput = defaultValue 92 | line, _ := s.readLine() 93 | s.reader.defaultInput = "" 94 | return line 95 | } 96 | 97 | func (s *shellActionsImpl) ReadPassword() string { 98 | return s.reader.readPassword() 99 | } 100 | 101 | func (s *shellActionsImpl) ReadPasswordErr() (string, error) { 102 | return s.reader.readPasswordErr() 103 | } 104 | 105 | func (s *shellActionsImpl) ReadMultiLinesFunc(f func(string) bool) string { 106 | lines, _ := s.readMultiLinesFunc(f) 107 | return lines 108 | } 109 | 110 | func (s *shellActionsImpl) ReadMultiLines(terminator string) string { 111 | return s.ReadMultiLinesFunc(func(line string) bool { 112 | if strings.HasSuffix(strings.TrimSpace(line), terminator) { 113 | return false 114 | } 115 | return true 116 | }) 117 | } 118 | 119 | func (s *shellActionsImpl) Println(val ...interface{}) { 120 | s.reader.buf.Truncate(0) 121 | fmt.Fprintln(s.writer, val...) 122 | } 123 | 124 | func (s *shellActionsImpl) Print(val ...interface{}) { 125 | s.reader.buf.Truncate(0) 126 | fmt.Fprint(s.reader.buf, val...) 127 | fmt.Fprint(s.writer, val...) 128 | } 129 | 130 | func (s *shellActionsImpl) Printf(format string, val ...interface{}) { 131 | s.reader.buf.Truncate(0) 132 | fmt.Fprintf(s.reader.buf, format, val...) 133 | fmt.Fprintf(s.writer, format, val...) 134 | } 135 | 136 | func (s *shellActionsImpl) MultiChoice(options []string, text string) int { 137 | choice := s.multiChoice(options, text, nil, false) 138 | return choice[0] 139 | } 140 | func (s *shellActionsImpl) Checklist(options []string, text string, init []int) []int { 141 | return s.multiChoice(options, text, init, true) 142 | } 143 | func (s *shellActionsImpl) SetPrompt(prompt string) { 144 | s.reader.prompt = prompt 145 | s.reader.scanner.SetPrompt(s.reader.rlPrompt()) 146 | } 147 | 148 | func (s *shellActionsImpl) SetMultiPrompt(prompt string) { 149 | s.reader.multiPrompt = prompt 150 | } 151 | 152 | func (s *shellActionsImpl) SetMultiChoicePrompt(prompt, spacer string) { 153 | strMultiChoice = prompt 154 | strMultiChoiceSpacer = spacer 155 | } 156 | func (s *shellActionsImpl) SetChecklistOptions(open, selected string) { 157 | strMultiChoiceOpen = open 158 | strMultiChoiceSelect = selected 159 | } 160 | 161 | func (s *shellActionsImpl) ShowPrompt(show bool) { 162 | s.reader.showPrompt = show 163 | s.reader.scanner.SetPrompt(s.reader.rlPrompt()) 164 | } 165 | 166 | func (s *shellActionsImpl) Cmds() []*Cmd { 167 | var cmds []*Cmd 168 | for _, cmd := range s.rootCmd.children { 169 | cmds = append(cmds, cmd) 170 | } 171 | return cmds 172 | } 173 | 174 | func (s *shellActionsImpl) ClearScreen() error { 175 | return clearScreen(s.Shell) 176 | } 177 | 178 | func (s *shellActionsImpl) ShowPaged(text string) error { 179 | return showPagedReader(s.Shell, strings.NewReader(text)) 180 | } 181 | 182 | func (s *shellActionsImpl) ShowPagedReader(r io.Reader) error { 183 | return showPagedReader(s.Shell, r) 184 | } 185 | 186 | func (s *shellActionsImpl) Stop() { 187 | s.stop() 188 | } 189 | 190 | func (s *shellActionsImpl) HelpText() string { 191 | return s.rootCmd.HelpText() 192 | } 193 | 194 | func showPagedReader(s *Shell, r io.Reader) error { 195 | var cmd *exec.Cmd 196 | 197 | if s.pager == "" { 198 | if runtime.GOOS == "windows" { 199 | s.pager = "more" 200 | } else { 201 | s.pager = "less" 202 | } 203 | } 204 | 205 | cmd = exec.Command(s.pager, s.pagerArgs...) 206 | cmd.Stdout = s.writer 207 | cmd.Stderr = s.writer 208 | cmd.Stdin = r 209 | return cmd.Run() 210 | } 211 | -------------------------------------------------------------------------------- /pkg/nostr/filter_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. 2 | 3 | package nostr 4 | 5 | import ( 6 | json "encoding/json" 7 | 8 | easyjson "github.com/mailru/easyjson" 9 | jlexer "github.com/mailru/easyjson/jlexer" 10 | jwriter "github.com/mailru/easyjson/jwriter" 11 | ) 12 | 13 | // suppress unused package warning 14 | var ( 15 | _ *json.RawMessage 16 | _ *jlexer.Lexer 17 | _ *jwriter.Writer 18 | _ easyjson.Marshaler 19 | ) 20 | 21 | func easyjson4d398eaaDecodeGithubComNbdWtfGoNostr(in *jlexer.Lexer, out *Filter) { 22 | isTopLevel := in.IsStart() 23 | if in.IsNull() { 24 | if isTopLevel { 25 | in.Consumed() 26 | } 27 | in.Skip() 28 | return 29 | } 30 | out.Tags = make(TagMap) 31 | in.Delim('{') 32 | for !in.IsDelim('}') { 33 | key := in.UnsafeFieldName(false) 34 | in.WantColon() 35 | if in.IsNull() { 36 | in.Skip() 37 | in.WantComma() 38 | continue 39 | } 40 | switch key { 41 | case "ids": 42 | if in.IsNull() { 43 | in.Skip() 44 | out.IDs = nil 45 | } else { 46 | in.Delim('[') 47 | if out.IDs == nil { 48 | if !in.IsDelim(']') { 49 | out.IDs = make([]string, 0, 20) 50 | } else { 51 | out.IDs = []string{} 52 | } 53 | } else { 54 | out.IDs = (out.IDs)[:0] 55 | } 56 | for !in.IsDelim(']') { 57 | var v1 string 58 | v1 = string(in.String()) 59 | out.IDs = append(out.IDs, v1) 60 | in.WantComma() 61 | } 62 | in.Delim(']') 63 | } 64 | case "kinds": 65 | if in.IsNull() { 66 | in.Skip() 67 | out.Kinds = nil 68 | } else { 69 | in.Delim('[') 70 | if out.Kinds == nil { 71 | if !in.IsDelim(']') { 72 | out.Kinds = make([]int, 0, 8) 73 | } else { 74 | out.Kinds = []int{} 75 | } 76 | } else { 77 | out.Kinds = (out.Kinds)[:0] 78 | } 79 | for !in.IsDelim(']') { 80 | var v2 int 81 | v2 = int(in.Int()) 82 | out.Kinds = append(out.Kinds, v2) 83 | in.WantComma() 84 | } 85 | in.Delim(']') 86 | } 87 | case "authors": 88 | if in.IsNull() { 89 | in.Skip() 90 | out.Authors = nil 91 | } else { 92 | in.Delim('[') 93 | if out.Authors == nil { 94 | if !in.IsDelim(']') { 95 | out.Authors = make([]string, 0, 40) 96 | } else { 97 | out.Authors = []string{} 98 | } 99 | } else { 100 | out.Authors = (out.Authors)[:0] 101 | } 102 | for !in.IsDelim(']') { 103 | var v3 string 104 | v3 = string(in.String()) 105 | out.Authors = append(out.Authors, v3) 106 | in.WantComma() 107 | } 108 | in.Delim(']') 109 | } 110 | case "since": 111 | if in.IsNull() { 112 | in.Skip() 113 | out.Since = nil 114 | } else { 115 | if out.Since == nil { 116 | out.Since = new(Timestamp) 117 | } 118 | *out.Since = Timestamp(in.Int64()) 119 | } 120 | case "until": 121 | if in.IsNull() { 122 | in.Skip() 123 | out.Until = nil 124 | } else { 125 | if out.Until == nil { 126 | out.Until = new(Timestamp) 127 | } 128 | *out.Until = Timestamp(in.Int64()) 129 | } 130 | case "limit": 131 | out.Limit = int(in.Int()) 132 | case "search": 133 | out.Search = string(in.String()) 134 | default: 135 | if len(key) > 1 && key[0] == '#' { 136 | tagValues := make([]string, 0, 40) 137 | if !in.IsNull() { 138 | in.Delim('[') 139 | if out.Authors == nil { 140 | if !in.IsDelim(']') { 141 | tagValues = make([]string, 0, 4) 142 | } else { 143 | tagValues = []string{} 144 | } 145 | } else { 146 | tagValues = (tagValues)[:0] 147 | } 148 | for !in.IsDelim(']') { 149 | var v3 string 150 | v3 = string(in.String()) 151 | tagValues = append(tagValues, v3) 152 | in.WantComma() 153 | } 154 | in.Delim(']') 155 | } 156 | out.Tags[key[1:]] = tagValues 157 | } else { 158 | in.SkipRecursive() 159 | } 160 | } 161 | in.WantComma() 162 | } 163 | in.Delim('}') 164 | if isTopLevel { 165 | in.Consumed() 166 | } 167 | } 168 | 169 | func easyjson4d398eaaEncodeGithubComNbdWtfGoNostr(out *jwriter.Writer, in Filter) { 170 | out.RawByte('{') 171 | first := true 172 | _ = first 173 | if len(in.IDs) != 0 { 174 | const prefix string = ",\"ids\":" 175 | first = false 176 | out.RawString(prefix[1:]) 177 | { 178 | out.RawByte('[') 179 | for v4, v5 := range in.IDs { 180 | if v4 > 0 { 181 | out.RawByte(',') 182 | } 183 | out.String(string(v5)) 184 | } 185 | out.RawByte(']') 186 | } 187 | } 188 | if len(in.Kinds) != 0 { 189 | const prefix string = ",\"kinds\":" 190 | if first { 191 | first = false 192 | out.RawString(prefix[1:]) 193 | } else { 194 | out.RawString(prefix) 195 | } 196 | { 197 | out.RawByte('[') 198 | for v6, v7 := range in.Kinds { 199 | if v6 > 0 { 200 | out.RawByte(',') 201 | } 202 | out.Int(int(v7)) 203 | } 204 | out.RawByte(']') 205 | } 206 | } 207 | if len(in.Authors) != 0 { 208 | const prefix string = ",\"authors\":" 209 | if first { 210 | first = false 211 | out.RawString(prefix[1:]) 212 | } else { 213 | out.RawString(prefix) 214 | } 215 | { 216 | out.RawByte('[') 217 | for v8, v9 := range in.Authors { 218 | if v8 > 0 { 219 | out.RawByte(',') 220 | } 221 | out.String(string(v9)) 222 | } 223 | out.RawByte(']') 224 | } 225 | } 226 | if in.Since != nil { 227 | const prefix string = ",\"since\":" 228 | if first { 229 | first = false 230 | out.RawString(prefix[1:]) 231 | } else { 232 | out.RawString(prefix) 233 | } 234 | out.Int64(int64(*in.Since)) 235 | } 236 | if in.Until != nil { 237 | const prefix string = ",\"until\":" 238 | if first { 239 | first = false 240 | out.RawString(prefix[1:]) 241 | } else { 242 | out.RawString(prefix) 243 | } 244 | out.Int64(int64(*in.Until)) 245 | } 246 | if in.Limit != 0 { 247 | const prefix string = ",\"limit\":" 248 | if first { 249 | first = false 250 | out.RawString(prefix[1:]) 251 | } else { 252 | out.RawString(prefix) 253 | } 254 | out.Int(int(in.Limit)) 255 | } 256 | if in.Search != "" { 257 | const prefix string = ",\"search\":" 258 | if first { 259 | first = false 260 | out.RawString(prefix[1:]) 261 | } else { 262 | out.RawString(prefix) 263 | } 264 | out.String(string(in.Search)) 265 | } 266 | for tag, values := range in.Tags { 267 | const prefix string = ",\"authors\":" 268 | if first { 269 | first = false 270 | out.RawString("\"#" + tag + "\":") 271 | } else { 272 | out.RawString(",\"#" + tag + "\":") 273 | } 274 | { 275 | out.RawByte('[') 276 | for i, v := range values { 277 | if i > 0 { 278 | out.RawByte(',') 279 | } 280 | out.String(string(v)) 281 | } 282 | out.RawByte(']') 283 | } 284 | } 285 | out.RawByte('}') 286 | } 287 | 288 | // MarshalJSON supports json.Marshaler interface 289 | func (v Filter) MarshalJSON() ([]byte, error) { 290 | w := jwriter.Writer{} 291 | easyjson4d398eaaEncodeGithubComNbdWtfGoNostr(&w, v) 292 | return w.Buffer.BuildBytes(), w.Error 293 | } 294 | 295 | // MarshalEasyJSON supports easyjson.Marshaler interface 296 | func (v Filter) MarshalEasyJSON(w *jwriter.Writer) { 297 | easyjson4d398eaaEncodeGithubComNbdWtfGoNostr(w, v) 298 | } 299 | 300 | // UnmarshalJSON supports json.Unmarshaler interface 301 | func (v *Filter) UnmarshalJSON(data []byte) error { 302 | r := jlexer.Lexer{Data: data} 303 | easyjson4d398eaaDecodeGithubComNbdWtfGoNostr(&r, v) 304 | return r.Error() 305 | } 306 | 307 | // UnmarshalEasyJSON supports easyjson.Unmarshaler interface 308 | func (v *Filter) UnmarshalEasyJSON(l *jlexer.Lexer) { 309 | easyjson4d398eaaDecodeGithubComNbdWtfGoNostr(l, v) 310 | } 311 | -------------------------------------------------------------------------------- /pkg/nostr/relay_test.go: -------------------------------------------------------------------------------- 1 | package nostr 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/http" 10 | "net/http/httptest" 11 | "sync" 12 | "testing" 13 | "time" 14 | 15 | "golang.org/x/net/websocket" 16 | ) 17 | 18 | func TestPublish(t *testing.T) { 19 | // test note to be sent over websocket 20 | priv, pub := makeKeyPair(t) 21 | textNote := Event{ 22 | Kind: 1, 23 | Content: "hello", 24 | CreatedAt: Timestamp(1672068534), // random fixed timestamp 25 | Tags: Tags{[]string{"foo", "bar"}}, 26 | PubKey: pub, 27 | } 28 | if err := textNote.Sign(priv); err != nil { 29 | t.Fatalf("textNote.Sign: %v", err) 30 | } 31 | 32 | // fake relay server 33 | var mu sync.Mutex // guards published to satisfy go test -race 34 | var published bool 35 | ws := newWebsocketServer(func(conn *websocket.Conn) { 36 | mu.Lock() 37 | published = true 38 | mu.Unlock() 39 | // verify the client sent exactly the textNote 40 | var raw []json.RawMessage 41 | if err := websocket.JSON.Receive(conn, &raw); err != nil { 42 | t.Errorf("websocket.JSON.Receive: %v", err) 43 | } 44 | event := parseEventMessage(t, raw) 45 | if !bytes.Equal(event.Serialize(), textNote.Serialize()) { 46 | t.Errorf("received event:\n%+v\nwant:\n%+v", event, textNote) 47 | } 48 | // send back an ok nip-20 command result 49 | res := []any{"OK", textNote.ID, true, ""} 50 | if err := websocket.JSON.Send(conn, res); err != nil { 51 | t.Errorf("websocket.JSON.Send: %v", err) 52 | } 53 | }) 54 | defer ws.Close() 55 | 56 | // connect a client and send the text note 57 | rl := mustRelayConnect(ws.URL) 58 | status, _ := rl.Publish(context.Background(), textNote) 59 | if status != PublishStatusSucceeded { 60 | t.Errorf("published status is %d, not %d", status, PublishStatusSucceeded) 61 | } 62 | 63 | if !published { 64 | t.Errorf("fake relay server saw no event") 65 | } 66 | } 67 | 68 | func TestPublishBlocked(t *testing.T) { 69 | // test note to be sent over websocket 70 | textNote := Event{Kind: 1, Content: "hello"} 71 | textNote.ID = textNote.GetID() 72 | 73 | // fake relay server 74 | ws := newWebsocketServer(func(conn *websocket.Conn) { 75 | // discard received message; not interested 76 | var raw []json.RawMessage 77 | if err := websocket.JSON.Receive(conn, &raw); err != nil { 78 | t.Errorf("websocket.JSON.Receive: %v", err) 79 | } 80 | // send back a not ok nip-20 command result 81 | res := []any{"OK", textNote.ID, false, "blocked"} 82 | websocket.JSON.Send(conn, res) 83 | }) 84 | defer ws.Close() 85 | 86 | // connect a client and send a text note 87 | rl := mustRelayConnect(ws.URL) 88 | status, _ := rl.Publish(context.Background(), textNote) 89 | if status != PublishStatusFailed { 90 | t.Errorf("published status is %d, not %d", status, PublishStatusFailed) 91 | } 92 | } 93 | 94 | func TestPublishWriteFailed(t *testing.T) { 95 | // test note to be sent over websocket 96 | textNote := Event{Kind: 1, Content: "hello"} 97 | textNote.ID = textNote.GetID() 98 | 99 | // fake relay server 100 | ws := newWebsocketServer(func(conn *websocket.Conn) { 101 | // reject receive - force send error 102 | conn.Close() 103 | }) 104 | defer ws.Close() 105 | 106 | // connect a client and send a text note 107 | rl := mustRelayConnect(ws.URL) 108 | // Force brief period of time so that publish always fails on closed socket. 109 | time.Sleep(1 * time.Millisecond) 110 | status, err := rl.Publish(context.Background(), textNote) 111 | if status != PublishStatusFailed { 112 | t.Errorf("published status is %d, not %d, err: %v", status, PublishStatusFailed, err) 113 | } 114 | } 115 | 116 | func TestConnectContext(t *testing.T) { 117 | // fake relay server 118 | var mu sync.Mutex // guards connected to satisfy go test -race 119 | var connected bool 120 | ws := newWebsocketServer(func(conn *websocket.Conn) { 121 | mu.Lock() 122 | connected = true 123 | mu.Unlock() 124 | io.ReadAll(conn) // discard all input 125 | }) 126 | defer ws.Close() 127 | 128 | // relay client 129 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 130 | defer cancel() 131 | r, err := RelayConnect(ctx, ws.URL) 132 | if err != nil { 133 | t.Fatalf("RelayConnectContext: %v", err) 134 | } 135 | defer r.Close() 136 | 137 | mu.Lock() 138 | defer mu.Unlock() 139 | if !connected { 140 | t.Error("fake relay server saw no client connect") 141 | } 142 | } 143 | 144 | func TestConnectContextCanceled(t *testing.T) { 145 | // fake relay server 146 | ws := newWebsocketServer(discardingHandler) 147 | defer ws.Close() 148 | 149 | // relay client 150 | ctx, cancel := context.WithCancel(context.Background()) 151 | cancel() // make ctx expired 152 | _, err := RelayConnect(ctx, ws.URL) 153 | if !errors.Is(err, context.Canceled) { 154 | t.Errorf("RelayConnectContext returned %v error; want context.Canceled", err) 155 | } 156 | } 157 | 158 | func TestConnectWithOrigin(t *testing.T) { 159 | // fake relay server 160 | // default handler requires origin golang.org/x/net/websocket 161 | ws := httptest.NewServer(websocket.Handler(discardingHandler)) 162 | defer ws.Close() 163 | 164 | // relay client 165 | r := &Relay{URL: NormalizeURL(ws.URL), RequestHeader: http.Header{"origin": {"https://example.com"}}} 166 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 167 | defer cancel() 168 | err := r.Connect(ctx) 169 | if err != nil { 170 | t.Errorf("unexpected error: %v", err) 171 | } 172 | } 173 | 174 | func discardingHandler(conn *websocket.Conn) { 175 | io.ReadAll(conn) // discard all input 176 | } 177 | 178 | func newWebsocketServer(handler func(*websocket.Conn)) *httptest.Server { 179 | return httptest.NewServer(&websocket.Server{ 180 | Handshake: anyOriginHandshake, 181 | Handler: handler, 182 | }) 183 | } 184 | 185 | // anyOriginHandshake is an alternative to default in golang.org/x/net/websocket 186 | // which checks for origin. nostr client sends no origin and it makes no difference 187 | // for the tests here anyway. 188 | var anyOriginHandshake = func(conf *websocket.Config, r *http.Request) error { 189 | return nil 190 | } 191 | 192 | func makeKeyPair(t *testing.T) (priv, pub string) { 193 | t.Helper() 194 | privkey := GeneratePrivateKey() 195 | pubkey, err := GetPublicKey(privkey) 196 | if err != nil { 197 | t.Fatalf("GetPublicKey(%q): %v", privkey, err) 198 | } 199 | return privkey, pubkey 200 | } 201 | 202 | func mustRelayConnect(url string) *Relay { 203 | rl, err := RelayConnect(context.Background(), url) 204 | if err != nil { 205 | panic(err.Error()) 206 | } 207 | return rl 208 | } 209 | 210 | func parseEventMessage(t *testing.T, raw []json.RawMessage) Event { 211 | t.Helper() 212 | if len(raw) < 2 { 213 | t.Fatalf("len(raw) = %d; want at least 2", len(raw)) 214 | } 215 | var typ string 216 | json.Unmarshal(raw[0], &typ) 217 | if typ != "EVENT" { 218 | t.Errorf("typ = %q; want EVENT", typ) 219 | } 220 | var event Event 221 | if err := json.Unmarshal(raw[1], &event); err != nil { 222 | t.Errorf("json.Unmarshal(`%s`): %v", string(raw[1]), err) 223 | } 224 | return event 225 | } 226 | 227 | // func parseSubscriptionMessage(t *testing.T, raw []json.RawMessage) (subid string, filters []Filter) { 228 | // t.Helper() 229 | // if len(raw) < 3 { 230 | // t.Fatalf("len(raw) = %d; want at least 3", len(raw)) 231 | // } 232 | // var typ string 233 | // json.Unmarshal(raw[0], &typ) 234 | // if typ != "REQ" { 235 | // t.Errorf("typ = %q; want REQ", typ) 236 | // } 237 | // var id string 238 | // if err := json.Unmarshal(raw[1], &id); err != nil { 239 | // t.Errorf("json.Unmarshal sub id: %v", err) 240 | // } 241 | // var ff []Filter 242 | // for i, b := range raw[2:] { 243 | // var f Filter 244 | // if err := json.Unmarshal(b, &f); err != nil { 245 | // t.Errorf("json.Unmarshal filter %d: %v", i, err) 246 | // } 247 | // ff = append(ff, f) 248 | // } 249 | // return id, ff 250 | // } 251 | -------------------------------------------------------------------------------- /pkg/ishell/README.md: -------------------------------------------------------------------------------- 1 | # ishell 2 | 3 | ishell is an interactive shell library for creating interactive cli applications. 4 | 5 | [![Go Reference](https://godocs.io/nrat/pkg/shell?status.svg)](https://godocs.io/nrat/pkg/shell) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/abiosoft/ishell)](https://goreportcard.com/report/github.com/abiosoft/ishell) 7 | 8 | ## Usage 9 | 10 | ```go 11 | import "strings" 12 | import "nrat/pkg/shell" 13 | 14 | func main(){ 15 | // create new shell. 16 | // by default, new shell includes 'exit', 'help' and 'clear' commands. 17 | shell := ishell.New() 18 | 19 | // display welcome info. 20 | shell.Println("Sample Interactive Shell") 21 | 22 | // register a function for "greet" command. 23 | shell.AddCmd(&ishell.Cmd{ 24 | Name: "greet", 25 | Help: "greet user", 26 | Func: func(c *ishell.Context) { 27 | c.Println("Hello", strings.Join(c.Args, " ")) 28 | }, 29 | }) 30 | 31 | // run shell 32 | shell.Run() 33 | } 34 | ``` 35 | 36 | Execution 37 | 38 | ``` 39 | Sample Interactive Shell 40 | >>> help 41 | 42 | Commands: 43 | clear clear the screen 44 | greet greet user 45 | exit exit the program 46 | help display help 47 | 48 | >>> greet Someone Somewhere 49 | Hello Someone Somewhere 50 | >>> exit 51 | $ 52 | ``` 53 | 54 | ### Reading input 55 | 56 | ```go 57 | // simulate an authentication 58 | shell.AddCmd(&ishell.Cmd{ 59 | Name: "login", 60 | Help: "simulate a login", 61 | Func: func(c *ishell.Context) { 62 | // disable the '>>>' for cleaner same line input. 63 | c.ShowPrompt(false) 64 | defer c.ShowPrompt(true) // yes, revert after login. 65 | 66 | // get username 67 | c.Print("Username: ") 68 | username := c.ReadLine() 69 | 70 | // get password. 71 | c.Print("Password: ") 72 | password := c.ReadPassword() 73 | 74 | ... // do something with username and password 75 | 76 | c.Println("Authentication Successful.") 77 | }, 78 | }) 79 | ``` 80 | 81 | Execution 82 | 83 | ``` 84 | >>> login 85 | Username: someusername 86 | Password: 87 | Authentication Successful. 88 | ``` 89 | 90 | ### Multiline input 91 | 92 | Builtin support for multiple lines. 93 | 94 | ``` 95 | >>> This is \ 96 | ... multi line 97 | 98 | >>> Cool that << EOF 99 | ... everything here goes 100 | ... as a single argument. 101 | ... EOF 102 | ``` 103 | 104 | User defined 105 | 106 | ```go 107 | shell.AddCmd(&ishell.Cmd{ 108 | Name: "multi", 109 | Help: "input in multiple lines", 110 | Func: func(c *ishell.Context) { 111 | c.Println("Input multiple lines and end with semicolon ';'.") 112 | lines := c.ReadMultiLines(";") 113 | c.Println("Done reading. You wrote:") 114 | c.Println(lines) 115 | }, 116 | }) 117 | ``` 118 | 119 | Execution 120 | 121 | ``` 122 | >>> multi 123 | Input multiple lines and end with semicolon ';'. 124 | >>> this is user defined 125 | ... multiline input; 126 | You wrote: 127 | this is user defined 128 | multiline input; 129 | ``` 130 | 131 | ### Keyboard interrupt 132 | 133 | Builtin interrupt handler. 134 | 135 | ``` 136 | >>> ^C 137 | Input Ctrl-C once more to exit 138 | >>> ^C 139 | Interrupted 140 | exit status 1 141 | ``` 142 | 143 | Custom 144 | 145 | ```go 146 | shell.Interrupt(func(count int, c *ishell.Context) { ... }) 147 | ``` 148 | 149 | ### Multiple Choice 150 | 151 | ```go 152 | func(c *ishell.Context) { 153 | choice := c.MultiChoice([]string{ 154 | "Golangers", 155 | "Go programmers", 156 | "Gophers", 157 | "Goers", 158 | }, "What are Go programmers called ?") 159 | if choice == 2 { 160 | c.Println("You got it!") 161 | } else { 162 | c.Println("Sorry, you're wrong.") 163 | } 164 | }, 165 | ``` 166 | 167 | Output 168 | 169 | ``` 170 | What are Go programmers called ? 171 | Golangers 172 | Go programmers 173 | > Gophers 174 | Goers 175 | 176 | You got it! 177 | ``` 178 | 179 | ### Checklist 180 | 181 | ```go 182 | func(c *ishell.Context) { 183 | languages := []string{"Python", "Go", "Haskell", "Rust"} 184 | choices := c.Checklist(languages, 185 | "What are your favourite programming languages ?", nil) 186 | out := func() []string { ... } // convert index to language 187 | c.Println("Your choices are", strings.Join(out(), ", ")) 188 | } 189 | ``` 190 | 191 | Output 192 | 193 | ``` 194 | What are your favourite programming languages ? 195 | Python 196 | ✓ Go 197 | Haskell 198 | >✓ Rust 199 | 200 | Your choices are Go, Rust 201 | ``` 202 | 203 | ### Progress Bar 204 | 205 | Determinate 206 | 207 | ```go 208 | func(c *ishell.Context) { 209 | c.ProgressBar().Start() 210 | for i := 0; i < 101; i++ { 211 | c.ProgressBar().Suffix(fmt.Sprint(" ", i, "%")) 212 | c.ProgressBar().Progress(i) 213 | ... // some background computation 214 | } 215 | c.ProgressBar().Stop() 216 | } 217 | ``` 218 | 219 | Output 220 | 221 | ``` 222 | [==========> ] 50% 223 | ``` 224 | 225 | Indeterminate 226 | 227 | ```go 228 | 229 | func(c *ishell.Context) { 230 | c.ProgressBar().Indeterminate(true) 231 | c.ProgressBar().Start() 232 | ... // some background computation 233 | c.ProgressBar().Stop() 234 | } 235 | ``` 236 | 237 | Output 238 | 239 | ``` 240 | [ ==== ] 241 | ``` 242 | 243 | Custom display using [briandowns/spinner](https://github.com/briandowns/spinner). 244 | 245 | ```go 246 | display := ishell.ProgressDisplayCharSet(spinner.CharSets[11]) 247 | func(c *Context) { c.ProgressBar().Display(display) ... } 248 | 249 | // or set it globally 250 | ishell.ProgressBar().Display(display) 251 | ``` 252 | 253 | ### Durable history 254 | 255 | ```go 256 | // Read and write history to $HOME/.ishell_history 257 | shell.SetHomeHistoryPath(".ishell_history") 258 | ``` 259 | 260 | ### Non-interactive execution 261 | 262 | In some situations it is desired to exit the program directly after executing a single command. 263 | 264 | ```go 265 | // when started with "exit" as first argument, assume non-interactive execution 266 | if len(os.Args) > 1 && os.Args[1] == "exit" { 267 | shell.Process(os.Args[2:]...) 268 | } else { 269 | // start shell 270 | shell.Run() 271 | } 272 | ``` 273 | 274 | ```bash 275 | # Run normally - interactive mode: 276 | $ go run main.go 277 | >>> | 278 | 279 | # Run non-interactivelly 280 | $ go run main.go exit greet Someusername 281 | Hello Someusername 282 | ``` 283 | 284 | ### Output with Color 285 | 286 | You can use [fatih/color](https://github.com/fatih/color). 287 | 288 | ```go 289 | func(c *ishell.Context) { 290 | yellow := color.New(color.FgYellow).SprintFunc() 291 | c.Println(yellow("This line is yellow")) 292 | } 293 | ``` 294 | 295 | Execution 296 | 297 | ```sh 298 | >>> color 299 | This line is yellow 300 | ``` 301 | 302 | ### Example 303 | 304 | Available [here](https://github.com/abiosoft/ishell/blob/master/example/main.go). 305 | 306 | ```sh 307 | go run example/main.go 308 | ``` 309 | 310 | ## Supported Platforms 311 | 312 | - [x] Linux 313 | - [x] OSX 314 | - [x] Windows [Not tested but should work] 315 | 316 | ## Note 317 | 318 | ishell is in active development and can still change significantly. 319 | 320 | ## Roadmap (in no particular order) 321 | 322 | - [x] Multiline inputs 323 | - [x] Command history 324 | - [x] Customizable tab completion 325 | - [x] Handle ^C interrupts 326 | - [x] Subcommands and help texts 327 | - [x] Scrollable paged output 328 | - [x] Progress bar 329 | - [x] Multiple choice prompt 330 | - [x] Checklist prompt 331 | - [x] Support for command aliases 332 | - [ ] Multiple line progress bars 333 | - [ ] Testing, testing, testing 334 | 335 | ## Contribution 336 | 337 | 1. Create an issue to discuss it. 338 | 2. Send in Pull Request. 339 | 340 | ## License 341 | 342 | MIT 343 | 344 | ## Credits 345 | 346 | | Library | Use | 347 | | ------------------------------------------------------------------------------ | -------------------------------------- | 348 | | [github.com/flynn-archive/go-shlex](https://github.com/flynn-archive/go-shlex) | splitting input into command and args. | 349 | | [github.com/chzyer/readline](https://github.com/chzyer/readline) | readline capabilities. | 350 | 351 | ## Donate 352 | 353 | ``` 354 | bitcoin: 1GTHYEDiy2C7RzXn5nY4wVRaEN2GvLjwZN 355 | paypal: a@abiosoft.com 356 | ``` 357 | -------------------------------------------------------------------------------- /pkg/ishell/progress.go: -------------------------------------------------------------------------------- 1 | package ishell 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sync" 7 | "time" 8 | "unicode/utf8" 9 | ) 10 | 11 | // ProgressDisplay handles the display string for 12 | // a progress bar. 13 | type ProgressDisplay interface { 14 | // Determinate returns the strings to display 15 | // for percents 0 to 100. 16 | Determinate() [101]string 17 | // Indeterminate returns the strings to display 18 | // at interval. 19 | Indeterminate() []string 20 | } 21 | 22 | // ProgressBar is an ishell progress bar. 23 | type ProgressBar interface { 24 | // Display sets the display of the progress bar. 25 | Display(ProgressDisplay) 26 | // Indeterminate sets the progress bar type 27 | // to indeterminate if true or determinate otherwise. 28 | Indeterminate(bool) 29 | // Interval sets the time between transitions for indeterminate 30 | // progress bar. 31 | Interval(time.Duration) 32 | // SetProgress sets the progress stage of the progress bar. 33 | // percent is from between 1 and 100. 34 | Progress(percent int) 35 | // Prefix sets the prefix for the output. The text to place before 36 | // the display. 37 | Prefix(string) 38 | // Suffix sets the suffix for the output. The text to place after 39 | // the display. 40 | Suffix(string) 41 | // Final sets the string to show after the progress bar is done. 42 | Final(string) 43 | // Start starts the progress bar. 44 | Start() 45 | // Stop stops the progress bar. 46 | Stop() 47 | } 48 | 49 | const progressInterval = time.Millisecond * 100 50 | 51 | type progressBarImpl struct { 52 | display ProgressDisplay 53 | indeterminate bool 54 | interval time.Duration 55 | iterator iterator 56 | percent int 57 | prefix string 58 | suffix string 59 | final string 60 | writer io.Writer 61 | writtenLen int 62 | running bool 63 | wait chan struct{} 64 | wMutex sync.Mutex 65 | sync.Mutex 66 | } 67 | 68 | func newProgressBar(s *Shell) ProgressBar { 69 | display := simpleProgressDisplay{} 70 | return &progressBarImpl{ 71 | interval: progressInterval, 72 | writer: s.writer, 73 | display: display, 74 | iterator: &stringIterator{set: display.Indeterminate()}, 75 | indeterminate: true, 76 | } 77 | } 78 | 79 | func (p *progressBarImpl) Display(display ProgressDisplay) { 80 | p.display = display 81 | } 82 | 83 | func (p *progressBarImpl) Indeterminate(b bool) { 84 | p.indeterminate = b 85 | } 86 | 87 | func (p *progressBarImpl) Interval(t time.Duration) { 88 | p.interval = t 89 | } 90 | 91 | func (p *progressBarImpl) Progress(percent int) { 92 | if percent < 0 { 93 | percent = 0 94 | } else if percent > 100 { 95 | percent = 100 96 | } 97 | p.percent = percent 98 | p.indeterminate = false 99 | p.refresh() 100 | } 101 | 102 | func (p *progressBarImpl) Prefix(prefix string) { 103 | p.prefix = prefix 104 | } 105 | 106 | func (p *progressBarImpl) Suffix(suffix string) { 107 | p.suffix = suffix 108 | } 109 | 110 | func (p *progressBarImpl) Final(s string) { 111 | p.final = s 112 | } 113 | 114 | func (p *progressBarImpl) write(s string) error { 115 | p.erase(p.writtenLen) 116 | p.writtenLen = utf8.RuneCountInString(s) 117 | _, err := p.writer.Write([]byte(s)) 118 | return err 119 | } 120 | 121 | func (p *progressBarImpl) erase(n int) { 122 | for i := 0; i < n; i++ { 123 | p.writer.Write([]byte{'\b'}) 124 | } 125 | } 126 | 127 | func (p *progressBarImpl) done() { 128 | p.wMutex.Lock() 129 | defer p.wMutex.Unlock() 130 | 131 | p.erase(p.writtenLen) 132 | fmt.Fprintln(p.writer, p.final) 133 | } 134 | 135 | func (p *progressBarImpl) output() string { 136 | p.Lock() 137 | defer p.Unlock() 138 | 139 | var display string 140 | if p.indeterminate { 141 | display = p.iterator.next() 142 | } else { 143 | display = p.display.Determinate()[p.percent] 144 | } 145 | return fmt.Sprintf("%s%s%s ", p.prefix, display, p.suffix) 146 | } 147 | 148 | func (p *progressBarImpl) refresh() { 149 | p.wMutex.Lock() 150 | defer p.wMutex.Unlock() 151 | 152 | p.write(p.output()) 153 | } 154 | 155 | func (p *progressBarImpl) Start() { 156 | p.Lock() 157 | p.running = true 158 | p.wait = make(chan struct{}) 159 | p.Unlock() 160 | 161 | go func() { 162 | for { 163 | var running, indeterminate bool 164 | p.Lock() 165 | running = p.running 166 | indeterminate = p.indeterminate 167 | p.Unlock() 168 | 169 | if !running { 170 | break 171 | } 172 | time.Sleep(p.interval) 173 | if indeterminate { 174 | p.refresh() 175 | } 176 | } 177 | p.done() 178 | close(p.wait) 179 | }() 180 | } 181 | 182 | func (p *progressBarImpl) Stop() { 183 | p.Lock() 184 | p.running = false 185 | p.Unlock() 186 | 187 | <-p.wait 188 | } 189 | 190 | // ProgressDisplayCharSet is the character set for 191 | // a progress bar. 192 | type ProgressDisplayCharSet []string 193 | 194 | // Determinate satisfies ProgressDisplay interface. 195 | func (p ProgressDisplayCharSet) Determinate() [101]string { 196 | // TODO everything here works but not pleasing to the eyes 197 | // and probably not optimal. 198 | // This should be cleaner. 199 | var set [101]string 200 | for i := range set { 201 | set[i] = p[len(p)-1] 202 | } 203 | // assumption is than len(p) <= 101 204 | step := 101 / len(p) 205 | for i, j := 0, 0; i < len(set) && j < len(p); i, j = i+step, j+1 { 206 | for k := 0; k < step && i+k < len(set); k++ { 207 | set[i+k] = p[j] 208 | } 209 | } 210 | return set 211 | } 212 | 213 | // Indeterminate satisfies ProgressDisplay interface. 214 | func (p ProgressDisplayCharSet) Indeterminate() []string { 215 | return p 216 | } 217 | 218 | // ProgressDisplayFunc is a convenience function to create a ProgressDisplay. 219 | // percent is -1 for indeterminate and 0-100 for determinate. 220 | type ProgressDisplayFunc func(percent int) string 221 | 222 | // Determinate satisfies ProgressDisplay interface. 223 | func (p ProgressDisplayFunc) Determinate() [101]string { 224 | var set [101]string 225 | for i := range set { 226 | set[i] = p(i) 227 | } 228 | return set 229 | } 230 | 231 | // Indeterminate satisfies ProgressDisplay interface. 232 | func (p ProgressDisplayFunc) Indeterminate() []string { 233 | // loop through until we get back to the first string 234 | set := []string{p(-1)} 235 | for { 236 | next := p(-1) 237 | if next == set[0] { 238 | break 239 | } 240 | set = append(set, next) 241 | } 242 | return set 243 | } 244 | 245 | type iterator interface { 246 | next() string 247 | } 248 | 249 | type stringIterator struct { 250 | index int 251 | set []string 252 | } 253 | 254 | func (s *stringIterator) next() string { 255 | current := s.set[s.index] 256 | s.index++ 257 | if s.index >= len(s.set) { 258 | s.index = 0 259 | } 260 | return current 261 | } 262 | 263 | var ( 264 | indeterminateCharSet = []string{ 265 | "[==== ]", "[ ==== ]", "[ ==== ]", 266 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 267 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 268 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 269 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 270 | "[ ==== ]", "[ ====]", 271 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 272 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 273 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 274 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 275 | "[ ==== ]", "[ ==== ]", "[ ==== ]", 276 | } 277 | determinateCharSet = []string{ 278 | "[ ]", "[> ]", "[=> ]", 279 | "[==> ]", "[===> ]", "[====> ]", 280 | "[=====> ]", "[======> ]", "[=======> ]", 281 | "[========> ]", "[=========> ]", "[==========> ]", 282 | "[===========> ]", "[============> ]", "[=============> ]", 283 | "[==============> ]", "[===============> ]", "[================> ]", 284 | "[=================> ]", "[==================> ]", "[===================>]", 285 | } 286 | ) 287 | 288 | type simpleProgressDisplay struct{} 289 | 290 | func (s simpleProgressDisplay) Determinate() [101]string { 291 | return ProgressDisplayCharSet(determinateCharSet).Determinate() 292 | } 293 | func (s simpleProgressDisplay) Indeterminate() []string { 294 | return indeterminateCharSet 295 | } 296 | --------------------------------------------------------------------------------