├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── bookmark.go ├── dm.go ├── extract.go ├── go.mod ├── go.sum ├── main.go ├── mcp.go ├── profile.go ├── timeline.go └── zap.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@master 13 | - name: Setup Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.x 17 | - name: Build 18 | run: go build -v . 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Setup Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.x 19 | - name: Cross build 20 | run: make cross 21 | - name: Create Release 22 | id: create_release 23 | uses: actions/create-release@master 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | - name: Upload 30 | run: make upload 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | algia 2 | algia.exe 3 | .dedup 4 | *.sh 5 | tmp 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Yasuhiro Matsumoto 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := algia 2 | VERSION := $$(make -s show-version) 3 | CURRENT_REVISION := $(shell git rev-parse --short HEAD) 4 | BUILD_LDFLAGS := "-s -w -X main.revision=$(CURRENT_REVISION)" 5 | GOBIN ?= $(shell go env GOPATH)/bin 6 | export GO111MODULE=on 7 | 8 | .PHONY: all 9 | all: clean build 10 | 11 | .PHONY: build 12 | build: 13 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) . 14 | 15 | .PHONY: install 16 | install: 17 | go install -ldflags=$(BUILD_LDFLAGS) . 18 | 19 | .PHONY: show-version 20 | show-version: $(GOBIN)/gobump 21 | gobump show -r . 22 | 23 | $(GOBIN)/gobump: 24 | go install github.com/x-motemen/gobump/cmd/gobump@latest 25 | 26 | .PHONY: cross 27 | cross: $(GOBIN)/goxz 28 | goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) . 29 | goxz -n $(BIN) -arch arm -os linux -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) . 30 | 31 | $(GOBIN)/goxz: 32 | go install github.com/Songmu/goxz/cmd/goxz@latest 33 | 34 | .PHONY: test 35 | test: build 36 | go test -v ./... 37 | 38 | .PHONY: clean 39 | clean: 40 | rm -rf $(BIN) goxz 41 | go clean 42 | 43 | .PHONY: bump 44 | bump: $(GOBIN)/gobump 45 | ifneq ($(shell git status --porcelain),) 46 | $(error git workspace is dirty) 47 | endif 48 | ifneq ($(shell git rev-parse --abbrev-ref HEAD),main) 49 | $(error current branch is not main) 50 | endif 51 | @gobump up -w . 52 | git commit -am "bump up version to $(VERSION)" 53 | git tag "v$(VERSION)" 54 | git push origin main 55 | git push origin "refs/tags/v$(VERSION)" 56 | 57 | .PHONY: upload 58 | upload: $(GOBIN)/ghr 59 | ghr "v$(VERSION)" goxz 60 | 61 | $(GOBIN)/ghr: 62 | go install github.com/tcnksm/ghr@latest 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # algia 2 | 3 | nostr CLI client written in Go 4 | 5 | ## Usage 6 | 7 | ``` 8 | NAME: 9 | algia - A cli application for nostr 10 | 11 | USAGE: 12 | algia [global options] command [command options] 13 | 14 | DESCRIPTION: 15 | A cli application for nostr 16 | 17 | COMMANDS: 18 | timeline, tl show timeline 19 | stream show stream 20 | post, n post new note 21 | reply, r reply to the note 22 | repost, b repost the note 23 | unrepost, B unrepost the note 24 | like, l like the note 25 | unlike, L unlike the note 26 | delete, d delete the note 27 | search, s search notes 28 | dm-list show DM list 29 | dm-timeline show DM timeline 30 | dm-post post new note 31 | profile show profile 32 | powa post ぽわ〜 33 | puru post ぷる 34 | zap zap [note|npub|nevent] 35 | version show version 36 | help, h Shows a list of commands or help for one command 37 | 38 | GLOBAL OPTIONS: 39 | -a value profile name 40 | --relays value relays 41 | -V verbose (default: false) 42 | --help, -h show help 43 | ``` 44 | 45 | ## Installation 46 | 47 | Download binary from Release page. 48 | 49 | Or install with go install command. 50 | ``` 51 | go install github.com/mattn/algia@latest 52 | ``` 53 | 54 | ## Configuration 55 | 56 | Minimal configuration. Need to be at ~/.config/algia/config.json 57 | 58 | ```json 59 | { 60 | "relays": { 61 | "wss://relay-jp.nostr.wirednet.jp": { 62 | "read": true, 63 | "write": true, 64 | "search": false 65 | } 66 | }, 67 | "privatekey": "nsecXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 68 | } 69 | ``` 70 | 71 | If you want to zap via Nostr Wallet Connect, please add `nwc-uri` which are provided from 72 | 73 | ```json 74 | { 75 | "relays": { 76 | ... 77 | }, 78 | "privatekey": "nsecXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 79 | "nwc-uri": "nostr+walletconnect://xxxxx" 80 | } 81 | ``` 82 | 83 | ## MCP 84 | 85 | ```json 86 | { 87 | "mcpServers": { 88 | "algia": { 89 | "command": "/path/to/algia", 90 | "args": [ 91 | "mcp" 92 | ] 93 | } 94 | } 95 | } 96 | ``` 97 | 98 | ## TODO 99 | 100 | * [x] like 101 | * [x] repost 102 | * [x] zap 103 | * [x] upload images 104 | 105 | ## FAQ 106 | 107 | Do you use proxy? then set environment variable `HTTP_PROXY` like below. 108 | 109 | HTTP_PROXY=http://myproxy.example.com:8080 110 | 111 | ## License 112 | 113 | MIT 114 | 115 | ## Author 116 | 117 | Yasuhiro Matsumoto (a.k.a. mattn) 118 | -------------------------------------------------------------------------------- /bookmark.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "github.com/nbd-wtf/go-nostr" 9 | "github.com/nbd-wtf/go-nostr/nip19" 10 | ) 11 | 12 | func doBMList(cCtx *cli.Context) error { 13 | n := cCtx.Int("n") 14 | j := cCtx.Bool("json") 15 | extra := cCtx.Bool("extra") 16 | 17 | cfg := cCtx.App.Metadata["config"].(*Config) 18 | 19 | // get followers 20 | followsMap, err := cfg.GetFollows(cCtx.String("a")) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | var sk string 26 | var npub string 27 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 28 | sk = s.(string) 29 | } else { 30 | return err 31 | } 32 | if npub, err = nostr.GetPublicKey(sk); err != nil { 33 | return err 34 | } 35 | 36 | // get timeline 37 | filter := nostr.Filter{ 38 | Kinds: []int{nostr.KindCategorizedBookmarksList}, 39 | Authors: []string{npub}, 40 | Tags: nostr.TagMap{"d": []string{"bookmark"}}, 41 | Limit: n, 42 | } 43 | 44 | be := []string{} 45 | evs := cfg.Events(filter) 46 | for _, ev := range evs { 47 | for _, tag := range ev.Tags { 48 | if len(tag) > 1 && tag[0] == "e" { 49 | be = append(be, tag[1:]...) 50 | } 51 | } 52 | } 53 | filter = nostr.Filter{ 54 | Kinds: []int{nostr.KindTextNote}, 55 | IDs: be, 56 | } 57 | eevs := cfg.Events(filter) 58 | cfg.PrintEvents(eevs, followsMap, j, extra) 59 | return nil 60 | } 61 | 62 | func doBMPost(cCtx *cli.Context) error { 63 | return errors.New("Not Implemented") 64 | } 65 | -------------------------------------------------------------------------------- /dm.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | "sync/atomic" 12 | 13 | "github.com/urfave/cli/v2" 14 | 15 | "github.com/fatih/color" 16 | "github.com/nbd-wtf/go-nostr" 17 | "github.com/nbd-wtf/go-nostr/nip04" 18 | "github.com/nbd-wtf/go-nostr/nip19" 19 | "github.com/nbd-wtf/go-nostr/sdk" 20 | ) 21 | 22 | func doDMList(cCtx *cli.Context) error { 23 | j := cCtx.Bool("json") 24 | 25 | cfg := cCtx.App.Metadata["config"].(*Config) 26 | 27 | // get followers 28 | followsMap, err := cfg.GetFollows(cCtx.String("a")) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | var sk string 34 | var npub string 35 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 36 | sk = s.(string) 37 | } else { 38 | return err 39 | } 40 | if npub, err = nostr.GetPublicKey(sk); err != nil { 41 | return err 42 | } 43 | 44 | // get timeline 45 | filter := nostr.Filter{ 46 | Kinds: []int{nostr.KindEncryptedDirectMessage}, 47 | Authors: []string{npub}, 48 | Limit: 9999, 49 | } 50 | 51 | evs := cfg.Events(filter) 52 | 53 | type entry struct { 54 | Name string `json:"name"` 55 | Pubkey string `json:"pubkey"` 56 | } 57 | users := []entry{} 58 | m := map[string]struct{}{} 59 | for _, ev := range evs { 60 | p := ev.Tags.GetFirst([]string{"p"}).Value() 61 | if _, ok := m[p]; ok { 62 | continue 63 | } 64 | if profile, ok := followsMap[p]; ok { 65 | m[p] = struct{}{} 66 | p, _ = nip19.EncodePublicKey(p) 67 | name := profile.DisplayName 68 | if name == "" { 69 | name = profile.Name 70 | } 71 | users = append(users, entry{ 72 | Name: name, 73 | Pubkey: p, 74 | }) 75 | } else { 76 | m[p] = struct{}{} 77 | p, _ = nip19.EncodePublicKey(p) 78 | users = append(users, entry{ 79 | Name: p, 80 | Pubkey: p, 81 | }) 82 | } 83 | } 84 | 85 | if j { 86 | for _, user := range users { 87 | json.NewEncoder(os.Stdout).Encode(user) 88 | } 89 | return nil 90 | } 91 | 92 | for _, user := range users { 93 | color.Set(color.FgHiBlue) 94 | fmt.Print(user.Pubkey) 95 | color.Set(color.Reset) 96 | fmt.Print(": ") 97 | color.Set(color.FgHiRed) 98 | fmt.Println(user.Name) 99 | color.Set(color.Reset) 100 | } 101 | return nil 102 | } 103 | 104 | func doDMTimeline(cCtx *cli.Context) error { 105 | u := cCtx.String("u") 106 | j := cCtx.Bool("json") 107 | extra := cCtx.Bool("extra") 108 | 109 | cfg := cCtx.App.Metadata["config"].(*Config) 110 | 111 | var sk string 112 | var npub string 113 | var err error 114 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 115 | sk = s.(string) 116 | } else { 117 | return err 118 | } 119 | if npub, err = nostr.GetPublicKey(sk); err != nil { 120 | return err 121 | } 122 | 123 | if u == "me" { 124 | u = npub 125 | } 126 | var pub string 127 | if pp := sdk.InputToProfile(context.TODO(), u); pp != nil { 128 | pub = pp.PublicKey 129 | } else { 130 | return fmt.Errorf("failed to parse pubkey from '%s'", u) 131 | } 132 | // get followers 133 | followsMap, err := cfg.GetFollows(cCtx.String("a")) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | // get timeline 139 | filter := nostr.Filter{ 140 | Kinds: []int{nostr.KindEncryptedDirectMessage}, 141 | Authors: []string{npub, pub}, 142 | Tags: nostr.TagMap{"p": []string{npub, pub}}, 143 | Limit: 9999, 144 | } 145 | 146 | evs := cfg.Events(filter) 147 | cfg.PrintEvents(evs, followsMap, j, extra) 148 | return nil 149 | } 150 | 151 | func doDMPost(cCtx *cli.Context) error { 152 | u := cCtx.String("u") 153 | stdin := cCtx.Bool("stdin") 154 | if !stdin && cCtx.Args().Len() == 0 { 155 | return cli.ShowSubcommandHelp(cCtx) 156 | } 157 | sensitive := cCtx.String("sensitive") 158 | 159 | cfg := cCtx.App.Metadata["config"].(*Config) 160 | 161 | var sk string 162 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 163 | sk = s.(string) 164 | } else { 165 | return err 166 | } 167 | ev := nostr.Event{} 168 | clientTag(&ev) 169 | 170 | if npub, err := nostr.GetPublicKey(sk); err == nil { 171 | if _, err := nip19.EncodePublicKey(npub); err != nil { 172 | return err 173 | } 174 | ev.PubKey = npub 175 | } else { 176 | return err 177 | } 178 | 179 | if stdin { 180 | b, err := io.ReadAll(os.Stdin) 181 | if err != nil { 182 | return err 183 | } 184 | ev.Content = string(b) 185 | } else { 186 | ev.Content = strings.Join(cCtx.Args().Slice(), "\n") 187 | } 188 | if strings.TrimSpace(ev.Content) == "" { 189 | return errors.New("content is empty") 190 | } 191 | 192 | if sensitive != "" { 193 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"content-warning", sensitive}) 194 | } 195 | 196 | if u == "me" { 197 | u = ev.PubKey 198 | } 199 | var pub string 200 | if pp := sdk.InputToProfile(context.TODO(), u); pp != nil { 201 | pub = pp.PublicKey 202 | } else { 203 | return fmt.Errorf("failed to parse pubkey from '%s'", u) 204 | } 205 | 206 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"p", pub}) 207 | ev.CreatedAt = nostr.Now() 208 | ev.Kind = nostr.KindEncryptedDirectMessage 209 | 210 | ss, err := nip04.ComputeSharedSecret(pub, sk) 211 | if err != nil { 212 | return err 213 | } 214 | ev.Content, err = nip04.Encrypt(ev.Content, ss) 215 | if err != nil { 216 | return err 217 | } 218 | if err := ev.Sign(sk); err != nil { 219 | return err 220 | } 221 | 222 | var success atomic.Int64 223 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 224 | err := relay.Publish(ctx, ev) 225 | if err != nil { 226 | fmt.Fprintln(os.Stderr, relay.URL, err) 227 | } else { 228 | success.Add(1) 229 | } 230 | return true 231 | }) 232 | if success.Load() == 0 { 233 | return errors.New("cannot post") 234 | } 235 | return nil 236 | } 237 | -------------------------------------------------------------------------------- /extract.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | urlPattern = `https?://[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+` 10 | mentionPattern = `@[a-zA-Z0-9.]+` 11 | emojiPattern = `:[a-zA-Z0-9]+:` 12 | tagPattern = `\B#\S+` 13 | ) 14 | 15 | var ( 16 | urlRe = regexp.MustCompile(urlPattern) 17 | mentionRe = regexp.MustCompile(mentionPattern) 18 | emojiRe = regexp.MustCompile(emojiPattern) 19 | tagRe = regexp.MustCompile(tagPattern) 20 | ) 21 | 22 | type entry struct { 23 | start int64 24 | end int64 25 | text string 26 | } 27 | 28 | func extractLinks(text string) []entry { 29 | var result []entry 30 | matches := urlRe.FindAllStringSubmatchIndex(text, -1) 31 | for _, m := range matches { 32 | result = append(result, entry{ 33 | text: text[m[0]:m[1]], 34 | start: int64(len([]rune(text[0:m[0]]))), 35 | end: int64(len([]rune(text[0:m[1]])))}, 36 | ) 37 | } 38 | return result 39 | } 40 | 41 | func extractMentions(text string) []entry { 42 | var result []entry 43 | matches := mentionRe.FindAllStringSubmatchIndex(text, -1) 44 | for _, m := range matches { 45 | result = append(result, entry{ 46 | text: strings.TrimPrefix(text[m[0]:m[1]], "@"), 47 | start: int64(len([]rune(text[0:m[0]]))), 48 | end: int64(len([]rune(text[0:m[1]])))}, 49 | ) 50 | } 51 | return result 52 | } 53 | 54 | func extractEmojis(text string) []entry { 55 | var result []entry 56 | matches := emojiRe.FindAllStringSubmatchIndex(text, -1) 57 | for _, m := range matches { 58 | result = append(result, entry{ 59 | text: text[m[0]:m[1]], 60 | start: int64(len([]rune(text[0:m[0]]))), 61 | end: int64(len([]rune(text[0:m[1]])))}, 62 | ) 63 | } 64 | return result 65 | } 66 | 67 | func extractTags(text string) []entry { 68 | var result []entry 69 | matches := tagRe.FindAllStringSubmatchIndex(text, -1) 70 | for _, m := range matches { 71 | result = append(result, entry{ 72 | text: strings.TrimPrefix(text[m[0]:m[1]], "#"), 73 | start: int64(len([]rune(text[0:m[0]]))), 74 | end: int64(len([]rune(text[0:m[1]])))}, 75 | ) 76 | } 77 | return result 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattn/algia 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.1 6 | 7 | require ( 8 | github.com/fatih/color v1.17.0 9 | github.com/mark3labs/mcp-go v0.20.1 10 | github.com/mdp/qrterminal/v3 v3.2.0 11 | github.com/nbd-wtf/go-nostr v0.38.1 12 | github.com/urfave/cli/v2 v2.27.4 13 | ) 14 | 15 | require ( 16 | github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect 17 | github.com/btcsuite/btcd/btcutil v1.1.6 // indirect 18 | github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect 19 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 20 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 21 | github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect 22 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect 23 | github.com/dustin/go-humanize v1.0.1 // indirect 24 | github.com/fiatjaf/eventstore v0.11.0 // indirect 25 | github.com/fiatjaf/generic-ristretto v0.0.1 // indirect 26 | github.com/gobwas/httphead v0.1.0 // indirect 27 | github.com/gobwas/pool v0.2.1 // indirect 28 | github.com/gobwas/ws v1.4.0 // indirect 29 | github.com/golang/glog v1.2.2 // indirect 30 | github.com/google/uuid v1.6.0 // indirect 31 | github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect 32 | github.com/josharian/intern v1.0.0 // indirect 33 | github.com/mailru/easyjson v0.7.7 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/pkg/errors v0.9.1 // indirect 37 | github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect 38 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 39 | github.com/tidwall/gjson v1.18.0 // indirect 40 | github.com/tidwall/match v1.1.1 // indirect 41 | github.com/tidwall/pretty v1.2.1 // indirect 42 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 43 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 44 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 45 | golang.org/x/sys v0.25.0 // indirect 46 | golang.org/x/term v0.24.0 // indirect 47 | rsc.io/qr v0.2.0 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 2 | github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= 3 | github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= 4 | github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= 5 | github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= 6 | github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= 7 | github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= 8 | github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= 9 | github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= 10 | github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= 11 | github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= 12 | github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= 13 | github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= 14 | github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= 15 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 16 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 17 | github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= 18 | github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 19 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 20 | github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 21 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 22 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 23 | github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= 24 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 25 | github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 26 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 27 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 28 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 29 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 30 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 31 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 32 | github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= 37 | github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= 38 | github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 39 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= 40 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= 41 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 42 | github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= 43 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= 44 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 45 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 46 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 47 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 48 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 49 | github.com/fiatjaf/eventstore v0.11.0 h1:4bKq8tw0CClNYSp7hAQ4YjLOVQ7Syeoe1ZWkROW7PwI= 50 | github.com/fiatjaf/eventstore v0.11.0/go.mod h1:oCHPB4TprrNjbhH2kjMKt1O48O1pk3VxAy5iZkB5Fb0= 51 | github.com/fiatjaf/generic-ristretto v0.0.1 h1:LUJSU87X/QWFsBXTwnH3moFe4N8AjUxT+Rfa0+bo6YM= 52 | github.com/fiatjaf/generic-ristretto v0.0.1/go.mod h1:cvV6ANHDA/GrfzVrig7N7i6l8CWnkVZvtQ2/wk9DPVE= 53 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 54 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 55 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 56 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 57 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 58 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 59 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 60 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 61 | github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= 62 | github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 63 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 64 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 65 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 66 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 67 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 68 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 69 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 70 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 71 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 72 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 73 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 74 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 75 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 76 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 77 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 78 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 79 | github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc= 80 | github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q= 81 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 82 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 83 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 84 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 85 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 86 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 87 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 88 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 89 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 90 | github.com/mark3labs/mcp-go v0.20.1 h1:E1Bbx9K8d8kQmDZ1QHblM38c7UU2evQ2LlkANk1U/zw= 91 | github.com/mark3labs/mcp-go v0.20.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= 92 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 93 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 94 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 95 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 96 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 97 | github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= 98 | github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= 99 | github.com/nbd-wtf/go-nostr v0.38.1 h1:D0moEtIpjhWs2zbgeRyokA4TOLzBdumtpL1/O7/frww= 100 | github.com/nbd-wtf/go-nostr v0.38.1/go.mod h1:TGKGj00BmJRXvRe0LlpDN3KKbELhhPXgBwUEhzu3Oq0= 101 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 102 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 103 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 104 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 105 | github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 106 | github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 107 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 108 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 109 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 110 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 111 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 112 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 113 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 114 | github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= 115 | github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= 116 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 117 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 118 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 119 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 120 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 121 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 122 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 123 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 124 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 125 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 126 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 127 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= 128 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 129 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 130 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 131 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 132 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 133 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 134 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 135 | github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= 136 | github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= 137 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 138 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 139 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 140 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 141 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 142 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 143 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 144 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 145 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= 146 | golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 147 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 148 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 149 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 150 | golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 151 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 152 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 153 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 154 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 155 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 156 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 159 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 162 | golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 166 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 167 | golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= 168 | golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= 169 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 170 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 171 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 172 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 173 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 174 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 175 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 176 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 177 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 178 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 179 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 180 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 181 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 182 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 183 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 184 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 185 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 186 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 187 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 188 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 189 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 190 | rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= 191 | rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= 192 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/urfave/cli/v2" 17 | 18 | "github.com/fatih/color" 19 | "github.com/nbd-wtf/go-nostr" 20 | "github.com/nbd-wtf/go-nostr/nip04" 21 | "github.com/nbd-wtf/go-nostr/nip19" 22 | ) 23 | 24 | const name = "algia" 25 | 26 | const version = "0.0.86" 27 | 28 | var revision = "HEAD" 29 | 30 | // Relay is 31 | type Relay struct { 32 | Read bool `json:"read"` 33 | Write bool `json:"write"` 34 | Search bool `json:"search"` 35 | } 36 | 37 | // Config is 38 | type Config struct { 39 | Relays map[string]Relay `json:"relays"` 40 | Follows map[string]Profile `json:"follows"` 41 | PrivateKey string `json:"privatekey"` 42 | Updated time.Time `json:"updated"` 43 | Emojis map[string]string `json:"emojis"` 44 | NwcURI string `json:"nwc-uri"` 45 | verbose bool 46 | tempRelay bool 47 | sk string 48 | } 49 | 50 | // Event is 51 | type Event struct { 52 | Event *nostr.Event `json:"event"` 53 | Profile Profile `json:"profile"` 54 | } 55 | 56 | // Profile is 57 | type Profile struct { 58 | Website string `json:"website"` 59 | Nip05 string `json:"nip05"` 60 | Picture string `json:"picture"` 61 | Lud16 string `json:"lud16"` 62 | DisplayName string `json:"display_name"` 63 | About string `json:"about"` 64 | Name string `json:"name"` 65 | Bot bool `json:"bot"` 66 | } 67 | 68 | func configDir() (string, error) { 69 | switch runtime.GOOS { 70 | case "darwin": 71 | dir, err := os.UserHomeDir() 72 | if err != nil { 73 | return "", err 74 | } 75 | return filepath.Join(dir, ".config"), nil 76 | default: 77 | return os.UserConfigDir() 78 | } 79 | } 80 | 81 | func loadConfig(profile string) (*Config, error) { 82 | dir, err := configDir() 83 | if err != nil { 84 | return nil, err 85 | } 86 | dir = filepath.Join(dir, "algia") 87 | 88 | var fp string 89 | if profile == "" { 90 | fp = filepath.Join(dir, "config.json") 91 | } else if profile == "?" { 92 | names, err := filepath.Glob(filepath.Join(dir, "config-*.json")) 93 | if err != nil { 94 | return nil, err 95 | } 96 | for _, name := range names { 97 | name = filepath.Base(name) 98 | name = strings.TrimLeft(name[6:len(name)-5], "-") 99 | fmt.Println(name) 100 | } 101 | os.Exit(0) 102 | } else { 103 | fp = filepath.Join(dir, "config-"+profile+".json") 104 | } 105 | os.MkdirAll(filepath.Dir(fp), 0700) 106 | 107 | b, err := os.ReadFile(fp) 108 | if err != nil { 109 | return nil, err 110 | } 111 | var cfg Config 112 | err = json.Unmarshal(b, &cfg) 113 | if err != nil { 114 | return nil, err 115 | } 116 | if len(cfg.Relays) == 0 { 117 | cfg.Relays = map[string]Relay{} 118 | cfg.Relays["wss://relay.nostr.band"] = Relay{ 119 | Read: true, 120 | Write: true, 121 | Search: true, 122 | } 123 | } 124 | return &cfg, nil 125 | } 126 | 127 | // GetFollows is 128 | func (cfg *Config) GetFollows(profile string) (map[string]Profile, error) { 129 | var mu sync.Mutex 130 | var pub string 131 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 132 | if pub, err = nostr.GetPublicKey(s.(string)); err != nil { 133 | return nil, err 134 | } 135 | } else { 136 | return nil, err 137 | } 138 | 139 | // get followers 140 | if (cfg.Updated.Add(3*time.Hour).Before(time.Now()) && !cfg.tempRelay) || len(cfg.Follows) == 0 { 141 | mu.Lock() 142 | cfg.Follows = map[string]Profile{} 143 | mu.Unlock() 144 | m := map[string]struct{}{} 145 | 146 | cfg.Do(Relay{Read: true}, func(ctx context.Context, relay *nostr.Relay) bool { 147 | if cfg.tempRelay == false { 148 | evs, _ := relay.QuerySync(ctx, nostr.Filter{Kinds: []int{nostr.KindRelayListMetadata}, Authors: []string{pub}, Limit: 1}) 149 | if len(evs) > 0 { 150 | rm := map[string]Relay{} 151 | for _, r := range evs[0].Tags.GetAll([]string{"r"}) { 152 | if len(r) == 2 { 153 | rm[r[1]] = Relay{ 154 | Read: true, 155 | Write: true, 156 | } 157 | } else if len(r) == 3 { 158 | switch r[2] { 159 | case "read": 160 | rm[r[1]] = Relay{ 161 | Read: true, 162 | Write: false, 163 | } 164 | case "write": 165 | rm[r[1]] = Relay{ 166 | Read: true, 167 | Write: true, 168 | } 169 | } 170 | } 171 | } 172 | for k, v1 := range cfg.Relays { 173 | if v2, ok := rm[k]; ok { 174 | v2.Search = v1.Search 175 | } 176 | } 177 | cfg.Relays = rm 178 | } 179 | } 180 | 181 | evs, _ := relay.QuerySync(ctx, nostr.Filter{Kinds: []int{nostr.KindFollowList}, Authors: []string{pub}, Limit: 1}) 182 | if len(evs) > 0 { 183 | for _, tag := range evs[0].Tags { 184 | if len(tag) >= 2 && tag[0] == "p" { 185 | mu.Lock() 186 | m[tag[1]] = struct{}{} 187 | mu.Unlock() 188 | } 189 | } 190 | } 191 | return true 192 | }) 193 | if cfg.verbose { 194 | fmt.Printf("found %d followers\n", len(m)) 195 | } 196 | if len(m) > 0 { 197 | follows := []string{} 198 | for k := range m { 199 | follows = append(follows, k) 200 | } 201 | 202 | for i := 0; i < len(follows); i += 500 { 203 | // Calculate the end index based on the current index and slice length 204 | end := i + 500 205 | if end > len(follows) { 206 | end = len(follows) 207 | } 208 | 209 | // get follower's descriptions 210 | cfg.Do(Relay{Read: true}, func(ctx context.Context, relay *nostr.Relay) bool { 211 | evs, err := relay.QuerySync(ctx, nostr.Filter{ 212 | Kinds: []int{nostr.KindProfileMetadata}, 213 | Authors: follows[i:end], // Use the updated end index 214 | }) 215 | if err != nil { 216 | return true 217 | } 218 | for _, ev := range evs { 219 | var profile Profile 220 | err := json.Unmarshal([]byte(ev.Content), &profile) 221 | if err == nil { 222 | mu.Lock() 223 | cfg.Follows[ev.PubKey] = profile 224 | mu.Unlock() 225 | } 226 | } 227 | return true 228 | }) 229 | } 230 | } 231 | 232 | cfg.Updated = time.Now() 233 | if err := cfg.save(profile); err != nil { 234 | return nil, err 235 | } 236 | } 237 | return cfg.Follows, nil 238 | } 239 | 240 | // FindRelay is 241 | func (cfg *Config) FindRelay(ctx context.Context, r Relay) *nostr.Relay { 242 | for k, v := range cfg.Relays { 243 | if r.Write && !v.Write { 244 | continue 245 | } 246 | if !cfg.tempRelay && r.Search && !v.Search { 247 | continue 248 | } 249 | if !r.Write && !v.Read { 250 | continue 251 | } 252 | if cfg.verbose { 253 | fmt.Printf("trying relay: %s\n", k) 254 | } 255 | relay, err := nostr.RelayConnect(ctx, k) 256 | if err != nil { 257 | if cfg.verbose { 258 | fmt.Fprintln(os.Stderr, err.Error()) 259 | } 260 | continue 261 | } 262 | return relay 263 | } 264 | return nil 265 | } 266 | 267 | // Do is 268 | func (cfg *Config) Do(r Relay, f func(context.Context, *nostr.Relay) bool) { 269 | var wg sync.WaitGroup 270 | ctx := context.Background() 271 | for k, v := range cfg.Relays { 272 | if r.Write && !v.Write { 273 | continue 274 | } 275 | if r.Search && !v.Search { 276 | continue 277 | } 278 | if !r.Write && !v.Read { 279 | continue 280 | } 281 | wg.Add(1) 282 | go func(wg *sync.WaitGroup, k string, v Relay) { 283 | defer wg.Done() 284 | relay, err := nostr.RelayConnect(ctx, k) 285 | if err != nil { 286 | if cfg.verbose { 287 | fmt.Fprintln(os.Stderr, err) 288 | } 289 | return 290 | } 291 | if !f(ctx, relay) { 292 | ctx.Done() 293 | } 294 | relay.Close() 295 | }(&wg, k, v) 296 | } 297 | wg.Wait() 298 | } 299 | 300 | func (cfg *Config) save(profile string) error { 301 | if cfg.tempRelay { 302 | return nil 303 | } 304 | dir, err := configDir() 305 | if err != nil { 306 | return err 307 | } 308 | dir = filepath.Join(dir, "algia") 309 | 310 | var fp string 311 | if profile == "" { 312 | fp = filepath.Join(dir, "config.json") 313 | } else { 314 | fp = filepath.Join(dir, "config-"+profile+".json") 315 | } 316 | b, err := json.MarshalIndent(&cfg, "", " ") 317 | if err != nil { 318 | return err 319 | } 320 | return os.WriteFile(fp, b, 0644) 321 | } 322 | 323 | // Decode is 324 | func (cfg *Config) Decode(ev *nostr.Event) error { 325 | var sk string 326 | var pub string 327 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 328 | sk = s.(string) 329 | if pub, err = nostr.GetPublicKey(s.(string)); err != nil { 330 | return err 331 | } 332 | } else { 333 | return err 334 | } 335 | tag := ev.Tags.GetFirst([]string{"p"}) 336 | sp := pub 337 | if tag != nil { 338 | sp = tag.Value() 339 | if sp != pub { 340 | if ev.PubKey != pub { 341 | return errors.New("is not author") 342 | } 343 | } else { 344 | sp = ev.PubKey 345 | } 346 | } 347 | ss, err := nip04.ComputeSharedSecret(sp, sk) 348 | if err != nil { 349 | return err 350 | } 351 | content, err := nip04.Decrypt(ev.Content, ss) 352 | if err != nil { 353 | return err 354 | } 355 | ev.Content = content 356 | return nil 357 | } 358 | 359 | // PrintEvents is 360 | func (cfg *Config) PrintEvents(evs []*nostr.Event, followsMap map[string]Profile, j, extra bool) { 361 | if j { 362 | if extra { 363 | var events []Event 364 | for _, ev := range evs { 365 | if profile, ok := followsMap[ev.PubKey]; ok { 366 | events = append(events, Event{ 367 | Event: ev, 368 | Profile: profile, 369 | }) 370 | } 371 | } 372 | for _, ev := range events { 373 | json.NewEncoder(os.Stdout).Encode(ev) 374 | } 375 | } else { 376 | for _, ev := range evs { 377 | json.NewEncoder(os.Stdout).Encode(ev) 378 | } 379 | } 380 | return 381 | } 382 | 383 | for _, ev := range evs { 384 | profile, ok := followsMap[ev.PubKey] 385 | if ok { 386 | color.Set(color.FgHiRed) 387 | fmt.Print(profile.Name) 388 | } else { 389 | color.Set(color.FgRed) 390 | if pk, err := nip19.EncodePublicKey(ev.PubKey); err == nil { 391 | fmt.Print(pk) 392 | } else { 393 | fmt.Print(ev.PubKey) 394 | } 395 | } 396 | color.Set(color.Reset) 397 | fmt.Print(": ") 398 | color.Set(color.FgHiBlue) 399 | if ni, err := nip19.EncodeNote(ev.ID); err == nil { 400 | fmt.Println(ni) 401 | } else { 402 | fmt.Println(ev.ID) 403 | } 404 | color.Set(color.Reset) 405 | fmt.Println(ev.Content) 406 | } 407 | } 408 | 409 | // Events is 410 | func (cfg *Config) Events(filter nostr.Filter) []*nostr.Event { 411 | rf := Relay{Read: true} 412 | if filter.Search != "" { 413 | rf.Search = true 414 | } 415 | var mu sync.Mutex 416 | found := false 417 | var m sync.Map 418 | cfg.Do(rf, func(ctx context.Context, relay *nostr.Relay) bool { 419 | mu.Lock() 420 | if found { 421 | mu.Unlock() 422 | return false 423 | } 424 | mu.Unlock() 425 | evs, err := relay.QuerySync(ctx, filter) 426 | if err != nil { 427 | return true 428 | } 429 | for _, ev := range evs { 430 | if _, ok := m.Load(ev.ID); !ok { 431 | if ev.Kind == nostr.KindEncryptedDirectMessage || ev.Kind == nostr.KindCategorizedBookmarksList { 432 | if err := cfg.Decode(ev); err != nil { 433 | continue 434 | } 435 | } 436 | m.LoadOrStore(ev.ID, ev) 437 | if len(filter.IDs) == 1 { 438 | mu.Lock() 439 | found = true 440 | ctx.Done() 441 | mu.Unlock() 442 | break 443 | } 444 | } 445 | } 446 | return true 447 | }) 448 | 449 | keys := []string{} 450 | m.Range(func(k, v any) bool { 451 | keys = append(keys, k.(string)) 452 | return true 453 | }) 454 | sort.Slice(keys, func(i, j int) bool { 455 | lhs, ok := m.Load(keys[i]) 456 | if !ok { 457 | return false 458 | } 459 | rhs, ok := m.Load(keys[j]) 460 | if !ok { 461 | return false 462 | } 463 | return lhs.(*nostr.Event).CreatedAt.Time().Before(rhs.(*nostr.Event).CreatedAt.Time()) 464 | }) 465 | var evs []*nostr.Event 466 | for _, key := range keys { 467 | vv, ok := m.Load(key) 468 | if !ok { 469 | continue 470 | } 471 | evs = append(evs, vv.(*nostr.Event)) 472 | } 473 | return evs 474 | } 475 | 476 | func doVersion(cCtx *cli.Context) error { 477 | fmt.Println(version) 478 | return nil 479 | } 480 | 481 | func clientTag(ev *nostr.Event) { 482 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"client", "algia", "31990:2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc:1727520612646", "wss://nostr.compile-error.net"}) 483 | } 484 | 485 | func main() { 486 | app := &cli.App{ 487 | Usage: "A cli application for nostr", 488 | Description: "A cli application for nostr", 489 | Flags: []cli.Flag{ 490 | &cli.StringFlag{Name: "a", Usage: "profile name"}, 491 | &cli.StringFlag{Name: "relays", Usage: "relays"}, 492 | &cli.BoolFlag{Name: "V", Usage: "verbose"}, 493 | }, 494 | Commands: []*cli.Command{ 495 | { 496 | Name: "timeline", 497 | Aliases: []string{"tl"}, 498 | Usage: "show timeline", 499 | Flags: []cli.Flag{ 500 | &cli.StringFlag{Name: "u", Usage: "user"}, 501 | &cli.IntFlag{Name: "n", Value: 30, Usage: "number of items"}, 502 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 503 | &cli.BoolFlag{Name: "extra", Usage: "extra JSON"}, 504 | &cli.BoolFlag{Name: "article", Usage: "show articles"}, 505 | }, 506 | Action: doTimeline, 507 | }, 508 | { 509 | Name: "stream", 510 | Usage: "show stream", 511 | Flags: []cli.Flag{ 512 | &cli.StringSliceFlag{Name: "author"}, 513 | &cli.IntSliceFlag{Name: "kind", Value: cli.NewIntSlice(nostr.KindTextNote)}, 514 | &cli.BoolFlag{Name: "follow"}, 515 | &cli.StringFlag{Name: "pattern"}, 516 | &cli.StringFlag{Name: "reply"}, 517 | &cli.StringSliceFlag{Name: "tag"}, 518 | }, 519 | Action: doStream, 520 | }, 521 | { 522 | Name: "post", 523 | Aliases: []string{"n"}, 524 | Flags: []cli.Flag{ 525 | &cli.StringSliceFlag{Name: "u", Usage: "users"}, 526 | &cli.BoolFlag{Name: "stdin"}, 527 | &cli.StringFlag{Name: "sensitive"}, 528 | &cli.StringSliceFlag{Name: "emoji"}, 529 | &cli.StringFlag{Name: "geohash"}, 530 | &cli.StringFlag{Name: "article-name"}, 531 | &cli.StringFlag{Name: "article-title"}, 532 | &cli.StringFlag{Name: "article-summary"}, 533 | }, 534 | Usage: "post new note", 535 | UsageText: "algia post [note text]", 536 | HelpName: "post", 537 | ArgsUsage: "[note text]", 538 | Action: doPost, 539 | }, 540 | { 541 | Name: "reply", 542 | Aliases: []string{"r"}, 543 | Flags: []cli.Flag{ 544 | &cli.BoolFlag{Name: "stdin"}, 545 | &cli.StringFlag{Name: "id", Required: true}, 546 | &cli.BoolFlag{Name: "quote"}, 547 | &cli.StringFlag{Name: "sensitive"}, 548 | &cli.StringSliceFlag{Name: "emoji"}, 549 | &cli.StringFlag{Name: "geohash"}, 550 | }, 551 | Usage: "reply to the note", 552 | UsageText: "algia reply --id [id] [note text]", 553 | HelpName: "reply", 554 | ArgsUsage: "[note text]", 555 | Action: doReply, 556 | }, 557 | { 558 | Name: "repost", 559 | Aliases: []string{"b"}, 560 | Flags: []cli.Flag{ 561 | &cli.StringFlag{Name: "id", Required: true}, 562 | }, 563 | Usage: "repost the note", 564 | UsageText: "algia repost --id [id]", 565 | HelpName: "repost", 566 | Action: doRepost, 567 | }, 568 | { 569 | Name: "unrepost", 570 | Aliases: []string{"B"}, 571 | Flags: []cli.Flag{ 572 | &cli.StringFlag{Name: "id", Required: true}, 573 | }, 574 | Usage: "unrepost the note", 575 | UsageText: "algia unrepost --id [id]", 576 | HelpName: "unrepost", 577 | Action: doUnrepost, 578 | }, 579 | { 580 | Name: "like", 581 | Aliases: []string{"l"}, 582 | Flags: []cli.Flag{ 583 | &cli.StringFlag{Name: "id", Required: true}, 584 | &cli.StringFlag{Name: "content"}, 585 | &cli.StringFlag{Name: "emoji"}, 586 | }, 587 | Usage: "like the note", 588 | UsageText: "algia like --id [id]", 589 | HelpName: "like", 590 | Action: doLike, 591 | }, 592 | { 593 | Name: "unlike", 594 | Aliases: []string{"L"}, 595 | Flags: []cli.Flag{ 596 | &cli.StringFlag{Name: "id", Required: true}, 597 | }, 598 | Usage: "unlike the note", 599 | UsageText: "algia unlike --id [id]", 600 | HelpName: "unlike", 601 | Action: doUnlike, 602 | }, 603 | { 604 | Name: "delete", 605 | Aliases: []string{"d"}, 606 | Flags: []cli.Flag{ 607 | &cli.StringFlag{Name: "id", Required: true}, 608 | }, 609 | Usage: "delete the note", 610 | UsageText: "algia delete --id [id]", 611 | HelpName: "delete", 612 | Action: doDelete, 613 | }, 614 | { 615 | Name: "search", 616 | Aliases: []string{"s"}, 617 | Flags: []cli.Flag{ 618 | &cli.IntFlag{Name: "n", Value: 30, Usage: "number of items"}, 619 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 620 | &cli.BoolFlag{Name: "extra", Usage: "extra JSON"}, 621 | }, 622 | Usage: "search notes", 623 | UsageText: "algia search [words]", 624 | HelpName: "search", 625 | Action: doSearch, 626 | }, 627 | { 628 | Name: "broadcast", 629 | Flags: []cli.Flag{ 630 | &cli.StringFlag{Name: "id", Required: true}, 631 | &cli.StringFlag{Name: "relay", Required: false}, 632 | }, 633 | Usage: "broadcast the note", 634 | UsageText: "algia broadcast --id [id]", 635 | HelpName: "broadcast", 636 | Action: doBroadcast, 637 | }, 638 | { 639 | Name: "dm-list", 640 | Flags: []cli.Flag{ 641 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 642 | }, 643 | Usage: "show DM list", 644 | UsageText: "algia dm-list", 645 | HelpName: "dm-list", 646 | Action: doDMList, 647 | }, 648 | { 649 | Name: "dm-timeline", 650 | Flags: []cli.Flag{ 651 | &cli.StringFlag{Name: "u", Value: "", Usage: "DM user", Required: true}, 652 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 653 | &cli.BoolFlag{Name: "extra", Usage: "extra JSON"}, 654 | }, 655 | Usage: "show DM timeline", 656 | UsageText: "algia dm-timeline", 657 | HelpName: "dm-timeline", 658 | Action: doDMTimeline, 659 | }, 660 | { 661 | Name: "dm-post", 662 | Flags: []cli.Flag{ 663 | &cli.StringFlag{Name: "u", Value: "", Usage: "DM user", Required: true}, 664 | &cli.BoolFlag{Name: "stdin"}, 665 | &cli.StringFlag{Name: "sensitive"}, 666 | }, 667 | Usage: "post new DM note", 668 | UsageText: "algia post [note text]", 669 | HelpName: "post", 670 | ArgsUsage: "[note text]", 671 | Action: doDMPost, 672 | }, 673 | { 674 | Name: "bm-list", 675 | Flags: []cli.Flag{ 676 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 677 | }, 678 | Usage: "show bookmarks", 679 | UsageText: "algia bm-list", 680 | HelpName: "bm-list", 681 | Action: doBMList, 682 | }, 683 | { 684 | Name: "bm-post", 685 | Usage: "post bookmark", 686 | UsageText: "algia bm-post [note]", 687 | HelpName: "bm-post", 688 | ArgsUsage: "[note]", 689 | Action: doBMPost, 690 | }, 691 | { 692 | Name: "profile", 693 | Flags: []cli.Flag{ 694 | &cli.StringFlag{Name: "u", Value: "", Usage: "user"}, 695 | &cli.BoolFlag{Name: "json", Usage: "output JSON"}, 696 | &cli.StringSliceFlag{Name: "set", Usage: "set attributes"}, 697 | }, 698 | Usage: "show profile", 699 | UsageText: "algia profile", 700 | HelpName: "profile", 701 | Action: doProfile, 702 | }, 703 | { 704 | Name: "update-profile", 705 | Usage: "update profile", 706 | UsageText: "algia update-profile", 707 | HelpName: "update-profile", 708 | Action: doUpdateProfile, 709 | }, 710 | { 711 | Name: "powa", 712 | Usage: "post ぽわ〜", 713 | UsageText: "algia powa", 714 | HelpName: "powa", 715 | Action: doPowa, 716 | }, 717 | { 718 | Name: "puru", 719 | Usage: "post ぷる", 720 | UsageText: "algia puru", 721 | HelpName: "puru", 722 | Action: doPuru, 723 | }, 724 | { 725 | Name: "mcp", 726 | Usage: "mcp server", 727 | UsageText: "algia mcp", 728 | HelpName: "mcp", 729 | Action: doMcp, 730 | }, 731 | { 732 | Name: "zap", 733 | Flags: []cli.Flag{ 734 | &cli.Uint64Flag{Name: "amount", Usage: "amount for zap", Value: 1}, 735 | &cli.StringFlag{Name: "comment", Usage: "comment for zap", Value: ""}, 736 | }, 737 | Usage: "zap something", 738 | UsageText: "algia zap [note|npub|nevent]", 739 | HelpName: "zap", 740 | Action: doZap, 741 | }, 742 | { 743 | Name: "event", 744 | Flags: []cli.Flag{ 745 | &cli.BoolFlag{Name: "stdin"}, 746 | &cli.IntFlag{Name: "kind", Required: true}, 747 | &cli.StringFlag{Name: "content"}, 748 | &cli.StringSliceFlag{Name: "tag"}, 749 | }, 750 | Usage: "send event", 751 | UsageText: "algia event ...", 752 | HelpName: "event", 753 | Action: doEvent, 754 | }, 755 | { 756 | Name: "version", 757 | Usage: "show version", 758 | UsageText: "algia version", 759 | HelpName: "version", 760 | Action: doVersion, 761 | }, 762 | }, 763 | Before: func(cCtx *cli.Context) error { 764 | if cCtx.Args().Get(0) == "version" { 765 | return nil 766 | } 767 | profile := cCtx.String("a") 768 | cfg, err := loadConfig(profile) 769 | if err != nil { 770 | return err 771 | } 772 | cCtx.App.Metadata = map[string]any{ 773 | "config": cfg, 774 | } 775 | cfg.verbose = cCtx.Bool("V") 776 | relays := cCtx.String("relays") 777 | if strings.TrimSpace(relays) != "" { 778 | cfg.Relays = make(map[string]Relay) 779 | for _, relay := range strings.Split(relays, ",") { 780 | cfg.Relays[relay] = Relay{ 781 | Read: true, 782 | Write: true, 783 | } 784 | } 785 | cfg.tempRelay = true 786 | } 787 | return nil 788 | }, 789 | } 790 | 791 | if err := app.Run(os.Args); err != nil { 792 | fmt.Fprintln(os.Stderr, err) 793 | os.Exit(1) 794 | } 795 | } 796 | -------------------------------------------------------------------------------- /mcp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/urfave/cli/v2" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | ) 11 | 12 | func required[T comparable](r mcp.CallToolRequest, p string) T { 13 | var zero T 14 | if _, ok := r.Params.Arguments[p]; !ok { 15 | return zero 16 | } 17 | if _, ok := r.Params.Arguments[p].(T); !ok { 18 | return zero 19 | } 20 | if r.Params.Arguments[p].(T) == zero { 21 | return zero 22 | } 23 | return r.Params.Arguments[p].(T) 24 | } 25 | 26 | func optional[T any](r mcp.CallToolRequest, p string) (T, bool) { 27 | var zero T 28 | if _, ok := r.Params.Arguments[p]; !ok { 29 | return zero, false 30 | } 31 | if _, ok := r.Params.Arguments[p].(T); !ok { 32 | return zero, false 33 | } 34 | return r.Params.Arguments[p].(T), true 35 | } 36 | 37 | func doMcp(cCtx *cli.Context) error { 38 | s := server.NewMCPServer( 39 | "algia", 40 | version, 41 | ) 42 | s.AddTool(mcp.NewTool("send_satoshi", 43 | mcp.WithDescription("send zap to note with specified amount"), 44 | mcp.WithString("note", mcp.Description("Note ID"), mcp.Required()), 45 | mcp.WithNumber("amount", mcp.Description("Zap amount satoshi to the note"), mcp.Required()), 46 | ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { 47 | err := callZap(&zapArg{ 48 | cfg: cCtx.App.Metadata["config"].(*Config), 49 | amount: required[uint64](r, "amount"), 50 | id: required[string](r, "note"), 51 | }) 52 | if err != nil { 53 | return mcp.NewToolResultError(err.Error()), nil 54 | } 55 | return mcp.NewToolResultText("OK"), nil 56 | }) 57 | 58 | s.AddTool(mcp.NewTool("favorite_nostr_event", 59 | mcp.WithDescription("favorite note"), 60 | mcp.WithString("id", mcp.Description("ID"), mcp.Required()), 61 | ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { 62 | err := callLike(&likeArg{ 63 | cfg: cCtx.App.Metadata["config"].(*Config), 64 | id: required[string](r, "note"), 65 | }) 66 | if err != nil { 67 | return mcp.NewToolResultError(err.Error()), nil 68 | } 69 | return mcp.NewToolResultText("OK"), nil 70 | }) 71 | 72 | s.AddTool(mcp.NewTool("publish_nostr_event", 73 | mcp.WithDescription("publish note"), 74 | mcp.WithString("content", mcp.Description("Content"), mcp.Required()), 75 | ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { 76 | err := callPost(&postArg{ 77 | cfg: cCtx.App.Metadata["config"].(*Config), 78 | content: required[string](r, "content"), 79 | }) 80 | if err != nil { 81 | return mcp.NewToolResultError(err.Error()), nil 82 | } 83 | return mcp.NewToolResultText("OK"), nil 84 | }) 85 | return server.ServeStdio(s) 86 | } 87 | -------------------------------------------------------------------------------- /profile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "sync/atomic" 10 | 11 | "github.com/urfave/cli/v2" 12 | 13 | "github.com/nbd-wtf/go-nostr" 14 | "github.com/nbd-wtf/go-nostr/nip19" 15 | "github.com/nbd-wtf/go-nostr/sdk" 16 | ) 17 | 18 | func doProfile(cCtx *cli.Context) error { 19 | user := cCtx.String("u") 20 | j := cCtx.Bool("json") 21 | 22 | cfg := cCtx.App.Metadata["config"].(*Config) 23 | relay := cfg.FindRelay(context.Background(), Relay{Read: true}) 24 | if relay == nil { 25 | return errors.New("cannot connect relays") 26 | } 27 | defer relay.Close() 28 | 29 | var pub string 30 | if user == "" { 31 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 32 | if pub, err = nostr.GetPublicKey(s.(string)); err != nil { 33 | return err 34 | } 35 | } else { 36 | return err 37 | } 38 | } else { 39 | if pp := sdk.InputToProfile(context.TODO(), user); pp != nil { 40 | pub = pp.PublicKey 41 | } else { 42 | pub = user 43 | } 44 | } 45 | 46 | // get set-metadata 47 | filter := nostr.Filter{ 48 | Kinds: []int{nostr.KindProfileMetadata}, 49 | Authors: []string{pub}, 50 | Limit: 1, 51 | } 52 | 53 | evs := cfg.Events(filter) 54 | if len(evs) == 0 { 55 | return errors.New("cannot find user") 56 | } 57 | 58 | if j { 59 | fmt.Fprintln(os.Stdout, evs[0].Content) 60 | return nil 61 | } 62 | var profile Profile 63 | err := json.Unmarshal([]byte(evs[0].Content), &profile) 64 | if err != nil { 65 | return err 66 | } 67 | npub, err := nip19.EncodePublicKey(pub) 68 | if err != nil { 69 | return err 70 | } 71 | fmt.Printf("Pubkey: %v\n", npub) 72 | fmt.Printf("Name: %v\n", profile.Name) 73 | fmt.Printf("DisplayName: %v\n", profile.DisplayName) 74 | fmt.Printf("WebSite: %v\n", profile.Website) 75 | fmt.Printf("Picture: %v\n", profile.Picture) 76 | fmt.Printf("NIP-05: %v\n", profile.Nip05) 77 | fmt.Printf("LUD-16: %v\n", profile.Lud16) 78 | fmt.Printf("About: %v\n", profile.About) 79 | fmt.Printf("Bot: %v\n", profile.Bot) 80 | return nil 81 | } 82 | 83 | func doUpdateProfile(cCtx *cli.Context) error { 84 | cfg := cCtx.App.Metadata["config"].(*Config) 85 | relay := cfg.FindRelay(context.Background(), Relay{Read: true}) 86 | if relay == nil { 87 | return errors.New("cannot connect relays") 88 | } 89 | defer relay.Close() 90 | 91 | var pub string 92 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 93 | if pub, err = nostr.GetPublicKey(s.(string)); err != nil { 94 | return err 95 | } 96 | } else { 97 | return err 98 | } 99 | 100 | // get set-metadata 101 | filter := nostr.Filter{ 102 | Kinds: []int{nostr.KindProfileMetadata}, 103 | Authors: []string{pub}, 104 | Limit: 1, 105 | } 106 | 107 | evs := cfg.Events(filter) 108 | if len(evs) == 0 { 109 | return errors.New("cannot find user") 110 | } 111 | 112 | ev := evs[0] 113 | 114 | var profile map[string]any 115 | err := json.Unmarshal([]byte(ev.Content), &profile) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | for _, arg := range cCtx.Args().Slice() { 121 | var set map[string]any 122 | err := json.Unmarshal([]byte(arg), &set) 123 | if err != nil { 124 | return err 125 | } 126 | for k, v := range set { 127 | if v == nil { 128 | delete(profile, k) 129 | } else { 130 | profile[k] = v 131 | } 132 | } 133 | } 134 | b, err := json.Marshal(profile) 135 | if err != nil { 136 | return err 137 | } 138 | ev.Content = string(b) 139 | 140 | var sk string 141 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 142 | sk = s.(string) 143 | } else { 144 | return err 145 | } 146 | 147 | clientTag(ev) 148 | ev.CreatedAt = nostr.Now() 149 | if err := ev.Sign(sk); err != nil { 150 | return err 151 | } 152 | 153 | var success atomic.Int64 154 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 155 | err := relay.Publish(ctx, *ev) 156 | if err != nil { 157 | fmt.Fprintln(os.Stderr, relay.URL, err) 158 | } else { 159 | success.Add(1) 160 | } 161 | return true 162 | }) 163 | if success.Load() == 0 { 164 | return errors.New("cannot post") 165 | } 166 | if cfg.verbose { 167 | if id, err := nip19.EncodeNote(ev.ID); err == nil { 168 | fmt.Println(id) 169 | } 170 | } 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /timeline.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "regexp" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | 15 | "github.com/urfave/cli/v2" 16 | 17 | "github.com/nbd-wtf/go-nostr" 18 | "github.com/nbd-wtf/go-nostr/nip19" 19 | "github.com/nbd-wtf/go-nostr/sdk" 20 | ) 21 | 22 | var usageError = errors.New("usage") 23 | 24 | func doPost(cCtx *cli.Context) error { 25 | stdin := cCtx.Bool("stdin") 26 | if !stdin && cCtx.Args().Len() == 0 { 27 | return cli.ShowSubcommandHelp(cCtx) 28 | } 29 | articleName := cCtx.String("article-name") 30 | articleTitle := cCtx.String("article-title") 31 | articleSummary := cCtx.String("article-summary") 32 | if articleName != "" && articleTitle == "" { 33 | return cli.ShowSubcommandHelp(cCtx) 34 | } 35 | 36 | var content string 37 | if stdin { 38 | b, err := ioutil.ReadAll(os.Stdin) 39 | if err != nil { 40 | return err 41 | } 42 | content = string(b) 43 | } else { 44 | content = strings.Join(cCtx.Args().Slice(), "\n") 45 | } 46 | return callPost(&postArg{ 47 | cfg: cCtx.App.Metadata["config"].(*Config), 48 | content: content, 49 | sensitive: cCtx.String("sensitive"), 50 | geohash: cCtx.String("geohash"), 51 | articleName: articleName, 52 | articleTitle: articleTitle, 53 | articleSummary: articleSummary, 54 | emoji: cCtx.StringSlice("emoji"), 55 | us: cCtx.StringSlice("u"), 56 | }) 57 | } 58 | 59 | type postArg struct { 60 | cfg *Config 61 | content string 62 | sensitive string 63 | geohash string 64 | articleName string 65 | articleTitle string 66 | articleSummary string 67 | emoji []string 68 | us []string 69 | } 70 | 71 | func callPost(arg *postArg) error { 72 | var sk string 73 | if _, s, err := nip19.Decode(arg.cfg.PrivateKey); err == nil { 74 | sk = s.(string) 75 | } else { 76 | return err 77 | } 78 | ev := nostr.Event{} 79 | if pub, err := nostr.GetPublicKey(sk); err == nil { 80 | if _, err := nip19.EncodePublicKey(pub); err != nil { 81 | return err 82 | } 83 | ev.PubKey = pub 84 | } else { 85 | return err 86 | } 87 | 88 | ev.Content = arg.content 89 | if strings.TrimSpace(ev.Content) == "" { 90 | return errors.New("content is empty") 91 | } 92 | 93 | ev.Tags = nostr.Tags{} 94 | clientTag(&ev) 95 | 96 | for _, entry := range extractLinks(ev.Content) { 97 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"r", entry.text}) 98 | } 99 | 100 | for _, u := range arg.emoji { 101 | tok := strings.SplitN(u, "=", 2) 102 | if len(tok) != 2 { 103 | return usageError 104 | } 105 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"emoji", tok[0], tok[1]}) 106 | } 107 | for _, entry := range extractEmojis(ev.Content) { 108 | name := strings.Trim(entry.text, ":") 109 | if icon, ok := arg.cfg.Emojis[name]; ok { 110 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"emoji", name, icon}) 111 | } 112 | } 113 | 114 | for i, u := range arg.us { 115 | ev.Content = fmt.Sprintf("#[%d] ", i) + ev.Content 116 | if pp := sdk.InputToProfile(context.TODO(), u); pp != nil { 117 | u = pp.PublicKey 118 | } else { 119 | return fmt.Errorf("failed to parse pubkey from '%s'", u) 120 | } 121 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"p", u}) 122 | } 123 | 124 | if arg.sensitive != "" { 125 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"content-warning", arg.sensitive}) 126 | } 127 | 128 | if arg.geohash != "" { 129 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"g", arg.geohash}) 130 | } 131 | 132 | hashtag := nostr.Tag{"t"} 133 | for _, m := range extractTags(ev.Content) { 134 | hashtag = append(hashtag, m.text) 135 | } 136 | if len(hashtag) > 1 { 137 | ev.Tags = ev.Tags.AppendUnique(hashtag) 138 | } 139 | 140 | ev.CreatedAt = nostr.Now() 141 | if arg.articleName != "" { 142 | ev.Kind = nostr.KindArticle 143 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"d", arg.articleName}) 144 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"title", arg.articleTitle}) 145 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"summary", arg.articleSummary}) 146 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"published_at", fmt.Sprint(nostr.Now())}) 147 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"a", fmt.Sprintf("%d:%s:%s", ev.Kind, ev.PubKey, arg.articleName), "wss://yabu.me"}) 148 | } else { 149 | ev.Kind = nostr.KindTextNote 150 | } 151 | if err := ev.Sign(sk); err != nil { 152 | return err 153 | } 154 | 155 | var success atomic.Int64 156 | arg.cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 157 | err := relay.Publish(ctx, ev) 158 | if err != nil { 159 | fmt.Fprintln(os.Stderr, relay.URL, err) 160 | } else { 161 | success.Add(1) 162 | } 163 | return true 164 | }) 165 | if success.Load() == 0 { 166 | return errors.New("cannot post") 167 | } 168 | if arg.cfg.verbose { 169 | if id, err := nip19.EncodeNote(ev.ID); err == nil { 170 | fmt.Println(id) 171 | } 172 | } 173 | return nil 174 | } 175 | 176 | func doEvent(cCtx *cli.Context) error { 177 | stdin := cCtx.Bool("stdin") 178 | kind := cCtx.Int("kind") 179 | content := cCtx.String("content") 180 | tags := cCtx.StringSlice("tag") 181 | 182 | cfg := cCtx.App.Metadata["config"].(*Config) 183 | 184 | var sk string 185 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 186 | sk = s.(string) 187 | } else { 188 | return err 189 | } 190 | ev := nostr.Event{} 191 | if pub, err := nostr.GetPublicKey(sk); err == nil { 192 | if _, err := nip19.EncodePublicKey(pub); err != nil { 193 | return err 194 | } 195 | ev.PubKey = pub 196 | } else { 197 | return err 198 | } 199 | 200 | if stdin { 201 | b, err := ioutil.ReadAll(os.Stdin) 202 | if err != nil { 203 | return err 204 | } 205 | ev.Content = string(b) 206 | } else { 207 | ev.Content = content 208 | } 209 | 210 | ev.Tags = nostr.Tags{} 211 | clientTag(&ev) 212 | 213 | for _, tag := range tags { 214 | name, value, found := strings.Cut(tag, "=") 215 | tag := []string{name} 216 | if found { 217 | // tags may also contain extra elements separated with a ";" 218 | tag = append(tag, strings.Split(value, ";")...) 219 | } 220 | ev.Tags = ev.Tags.AppendUnique(tag) 221 | } 222 | 223 | ev.Kind = kind 224 | ev.CreatedAt = nostr.Now() 225 | 226 | if err := ev.Sign(sk); err != nil { 227 | return err 228 | } 229 | 230 | var success atomic.Int64 231 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 232 | err := relay.Publish(ctx, ev) 233 | if err != nil { 234 | fmt.Fprintln(os.Stderr, relay.URL, err) 235 | } else { 236 | success.Add(1) 237 | } 238 | return true 239 | }) 240 | if success.Load() == 0 { 241 | return errors.New("cannot post") 242 | } 243 | if cfg.verbose { 244 | if id, err := nip19.EncodeNote(ev.ID); err == nil { 245 | fmt.Println(id) 246 | } 247 | } 248 | return nil 249 | } 250 | 251 | func doReply(cCtx *cli.Context) error { 252 | stdin := cCtx.Bool("stdin") 253 | id := cCtx.String("id") 254 | quote := cCtx.Bool("quote") 255 | if !stdin && cCtx.Args().Len() == 0 { 256 | return cli.ShowSubcommandHelp(cCtx) 257 | } 258 | sensitive := cCtx.String("sensitive") 259 | geohash := cCtx.String("geohash") 260 | 261 | cfg := cCtx.App.Metadata["config"].(*Config) 262 | 263 | var sk string 264 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 265 | sk = s.(string) 266 | } else { 267 | return err 268 | } 269 | ev := nostr.Event{} 270 | if pub, err := nostr.GetPublicKey(sk); err == nil { 271 | if _, err := nip19.EncodePublicKey(pub); err != nil { 272 | return err 273 | } 274 | ev.PubKey = pub 275 | } else { 276 | return err 277 | } 278 | 279 | if evp := sdk.InputToEventPointer(id); evp != nil { 280 | id = evp.ID 281 | } else { 282 | return fmt.Errorf("failed to parse event from '%s'", id) 283 | } 284 | 285 | ev.CreatedAt = nostr.Now() 286 | ev.Kind = nostr.KindTextNote 287 | if stdin { 288 | b, err := ioutil.ReadAll(os.Stdin) 289 | if err != nil { 290 | return err 291 | } 292 | ev.Content = string(b) 293 | } else { 294 | ev.Content = strings.Join(cCtx.Args().Slice(), "\n") 295 | } 296 | if strings.TrimSpace(ev.Content) == "" { 297 | return errors.New("content is empty") 298 | } 299 | 300 | ev.Tags = nostr.Tags{} 301 | clientTag(&ev) 302 | 303 | for _, entry := range extractLinks(ev.Content) { 304 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"r", entry.text}) 305 | } 306 | 307 | for _, u := range cCtx.StringSlice("emoji") { 308 | tok := strings.SplitN(u, "=", 2) 309 | if len(tok) != 2 { 310 | return cli.ShowSubcommandHelp(cCtx) 311 | } 312 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"emoji", tok[0], tok[1]}) 313 | } 314 | for _, entry := range extractEmojis(ev.Content) { 315 | name := strings.Trim(entry.text, ":") 316 | if icon, ok := cfg.Emojis[name]; ok { 317 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"emoji", name, icon}) 318 | } 319 | } 320 | 321 | if sensitive != "" { 322 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"content-warning", sensitive}) 323 | } 324 | 325 | if geohash != "" { 326 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"g", geohash}) 327 | } 328 | 329 | hashtag := nostr.Tag{"t"} 330 | for _, m := range extractTags(ev.Content) { 331 | hashtag = append(hashtag, m.text) 332 | } 333 | if len(hashtag) > 1 { 334 | ev.Tags = ev.Tags.AppendUnique(hashtag) 335 | } 336 | 337 | var success atomic.Int64 338 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 339 | if !quote { 340 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", id, relay.URL, "reply"}) 341 | } else { 342 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", id, relay.URL, "mention"}) 343 | } 344 | if err := ev.Sign(sk); err != nil { 345 | return true 346 | } 347 | err := relay.Publish(ctx, ev) 348 | if err != nil { 349 | fmt.Fprintln(os.Stderr, relay.URL, err) 350 | } else { 351 | success.Add(1) 352 | } 353 | return true 354 | }) 355 | if success.Load() == 0 { 356 | return errors.New("cannot reply") 357 | } 358 | return nil 359 | } 360 | 361 | func doRepost(cCtx *cli.Context) error { 362 | id := cCtx.String("id") 363 | 364 | cfg := cCtx.App.Metadata["config"].(*Config) 365 | 366 | ev := nostr.Event{} 367 | var sk string 368 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 369 | sk = s.(string) 370 | } else { 371 | return err 372 | } 373 | if pub, err := nostr.GetPublicKey(sk); err == nil { 374 | if _, err := nip19.EncodePublicKey(pub); err != nil { 375 | return err 376 | } 377 | ev.PubKey = pub 378 | } else { 379 | return err 380 | } 381 | 382 | if evp := sdk.InputToEventPointer(id); evp != nil { 383 | id = evp.ID 384 | } else { 385 | return fmt.Errorf("failed to parse event from '%s'", id) 386 | } 387 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", id}) 388 | filter := nostr.Filter{ 389 | Kinds: []int{nostr.KindTextNote}, 390 | IDs: []string{id}, 391 | } 392 | 393 | ev.CreatedAt = nostr.Now() 394 | ev.Kind = nostr.KindRepost 395 | ev.Content = "" 396 | 397 | var first atomic.Bool 398 | first.Store(true) 399 | 400 | var success atomic.Int64 401 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 402 | if first.Load() { 403 | evs, err := relay.QuerySync(ctx, filter) 404 | if err != nil { 405 | return true 406 | } 407 | for _, tmp := range evs { 408 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"p", tmp.ID}) 409 | } 410 | first.Store(false) 411 | if err := ev.Sign(sk); err != nil { 412 | return true 413 | } 414 | } 415 | err := relay.Publish(ctx, ev) 416 | if err != nil { 417 | fmt.Fprintln(os.Stderr, relay.URL, err) 418 | } else { 419 | success.Add(1) 420 | } 421 | return true 422 | }) 423 | if success.Load() == 0 { 424 | return errors.New("cannot repost") 425 | } 426 | return nil 427 | } 428 | 429 | func doUnrepost(cCtx *cli.Context) error { 430 | id := cCtx.String("id") 431 | if evp := sdk.InputToEventPointer(id); evp != nil { 432 | id = evp.ID 433 | } else { 434 | return fmt.Errorf("failed to parse event from '%s'", id) 435 | } 436 | 437 | cfg := cCtx.App.Metadata["config"].(*Config) 438 | 439 | var sk string 440 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 441 | sk = s.(string) 442 | } else { 443 | return err 444 | } 445 | pub, err := nostr.GetPublicKey(sk) 446 | if err != nil { 447 | return err 448 | } 449 | filter := nostr.Filter{ 450 | Kinds: []int{nostr.KindRepost}, 451 | Authors: []string{pub}, 452 | Tags: nostr.TagMap{"e": []string{id}}, 453 | } 454 | var repostID string 455 | var mu sync.Mutex 456 | cfg.Do(Relay{Read: true}, func(ctx context.Context, relay *nostr.Relay) bool { 457 | evs, err := relay.QuerySync(ctx, filter) 458 | if err != nil { 459 | return true 460 | } 461 | mu.Lock() 462 | if len(evs) > 0 && repostID == "" { 463 | repostID = evs[0].ID 464 | } 465 | mu.Unlock() 466 | return true 467 | }) 468 | 469 | var ev nostr.Event 470 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", repostID}) 471 | ev.CreatedAt = nostr.Now() 472 | ev.Kind = nostr.KindDeletion 473 | if err := ev.Sign(sk); err != nil { 474 | return err 475 | } 476 | 477 | var success atomic.Int64 478 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 479 | err := relay.Publish(ctx, ev) 480 | if err != nil { 481 | fmt.Fprintln(os.Stderr, relay.URL, err) 482 | } else { 483 | success.Add(1) 484 | } 485 | return true 486 | }) 487 | if success.Load() == 0 { 488 | return errors.New("cannot unrepost") 489 | } 490 | return nil 491 | } 492 | 493 | func doLike(cCtx *cli.Context) error { 494 | return callLike(&likeArg{ 495 | cfg: cCtx.App.Metadata["config"].(*Config), 496 | id: cCtx.String("id"), 497 | content: cCtx.String("content"), 498 | emoji: cCtx.String("emoji"), 499 | }) 500 | } 501 | 502 | type likeArg struct { 503 | cfg *Config 504 | id string 505 | content string 506 | emoji string 507 | } 508 | 509 | func callLike(arg *likeArg) error { 510 | ev := nostr.Event{} 511 | var sk string 512 | if _, s, err := nip19.Decode(arg.cfg.PrivateKey); err == nil { 513 | sk = s.(string) 514 | } else { 515 | return err 516 | } 517 | if pub, err := nostr.GetPublicKey(sk); err == nil { 518 | if _, err := nip19.EncodePublicKey(pub); err != nil { 519 | return err 520 | } 521 | ev.PubKey = pub 522 | } else { 523 | return err 524 | } 525 | 526 | if evp := sdk.InputToEventPointer(arg.id); evp != nil { 527 | arg.id = evp.ID 528 | } else { 529 | return fmt.Errorf("failed to parse event from '%s'", arg.id) 530 | } 531 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", arg.id}) 532 | filter := nostr.Filter{ 533 | Kinds: []int{nostr.KindTextNote}, 534 | IDs: []string{arg.id}, 535 | } 536 | 537 | ev.CreatedAt = nostr.Now() 538 | ev.Kind = nostr.KindReaction 539 | ev.Content = arg.content 540 | if arg.emoji != "" { 541 | if ev.Content == "" { 542 | ev.Content = "like" 543 | } 544 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"emoji", ev.Content, arg.emoji}) 545 | ev.Content = ":" + ev.Content + ":" 546 | } 547 | if ev.Content == "" { 548 | ev.Content = "+" 549 | } 550 | 551 | var first atomic.Bool 552 | first.Store(true) 553 | 554 | var success atomic.Int64 555 | arg.cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 556 | if first.Load() { 557 | evs, err := relay.QuerySync(ctx, filter) 558 | if err != nil { 559 | return true 560 | } 561 | for _, tmp := range evs { 562 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"p", tmp.ID}) 563 | } 564 | first.Store(false) 565 | if err := ev.Sign(sk); err != nil { 566 | return true 567 | } 568 | return true 569 | } 570 | err := relay.Publish(ctx, ev) 571 | if err != nil { 572 | fmt.Fprintln(os.Stderr, relay.URL, err) 573 | } else { 574 | success.Add(1) 575 | } 576 | return true 577 | }) 578 | if success.Load() == 0 { 579 | return errors.New("cannot like") 580 | } 581 | return nil 582 | } 583 | 584 | func doUnlike(cCtx *cli.Context) error { 585 | id := cCtx.String("id") 586 | if evp := sdk.InputToEventPointer(id); evp != nil { 587 | id = evp.ID 588 | } else { 589 | return fmt.Errorf("failed to parse event from '%s'", id) 590 | } 591 | 592 | cfg := cCtx.App.Metadata["config"].(*Config) 593 | 594 | var sk string 595 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 596 | sk = s.(string) 597 | } else { 598 | return err 599 | } 600 | pub, err := nostr.GetPublicKey(sk) 601 | if err != nil { 602 | return err 603 | } 604 | filter := nostr.Filter{ 605 | Kinds: []int{nostr.KindReaction}, 606 | Authors: []string{pub}, 607 | Tags: nostr.TagMap{"e": []string{id}}, 608 | } 609 | var likeID string 610 | var mu sync.Mutex 611 | cfg.Do(Relay{Read: true}, func(ctx context.Context, relay *nostr.Relay) bool { 612 | evs, err := relay.QuerySync(ctx, filter) 613 | if err != nil { 614 | return true 615 | } 616 | mu.Lock() 617 | if len(evs) > 0 && likeID == "" { 618 | likeID = evs[0].ID 619 | } 620 | mu.Unlock() 621 | return true 622 | }) 623 | 624 | var ev nostr.Event 625 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", likeID}) 626 | ev.CreatedAt = nostr.Now() 627 | ev.Kind = nostr.KindDeletion 628 | if err := ev.Sign(sk); err != nil { 629 | return err 630 | } 631 | 632 | var success atomic.Int64 633 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 634 | err := relay.Publish(ctx, ev) 635 | if err != nil { 636 | fmt.Fprintln(os.Stderr, relay.URL, err) 637 | } else { 638 | success.Add(1) 639 | } 640 | return true 641 | }) 642 | if success.Load() == 0 { 643 | return errors.New("cannot unlike") 644 | } 645 | return nil 646 | } 647 | 648 | func doDelete(cCtx *cli.Context) error { 649 | id := cCtx.String("id") 650 | 651 | cfg := cCtx.App.Metadata["config"].(*Config) 652 | 653 | ev := nostr.Event{} 654 | var sk string 655 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 656 | sk = s.(string) 657 | } else { 658 | return err 659 | } 660 | if pub, err := nostr.GetPublicKey(sk); err == nil { 661 | if _, err := nip19.EncodePublicKey(pub); err != nil { 662 | return err 663 | } 664 | ev.PubKey = pub 665 | } else { 666 | return err 667 | } 668 | 669 | if evp := sdk.InputToEventPointer(id); evp != nil { 670 | id = evp.ID 671 | } else { 672 | return fmt.Errorf("failed to parse event from '%s'", id) 673 | } 674 | ev.Tags = ev.Tags.AppendUnique(nostr.Tag{"e", id}) 675 | ev.CreatedAt = nostr.Now() 676 | ev.Kind = nostr.KindDeletion 677 | if err := ev.Sign(sk); err != nil { 678 | return err 679 | } 680 | 681 | var success atomic.Int64 682 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 683 | err := relay.Publish(ctx, ev) 684 | if err != nil { 685 | fmt.Fprintln(os.Stderr, relay.URL, err) 686 | } else { 687 | success.Add(1) 688 | } 689 | return true 690 | }) 691 | if success.Load() == 0 { 692 | return errors.New("cannot delete") 693 | } 694 | return nil 695 | } 696 | 697 | func doSearch(cCtx *cli.Context) error { 698 | n := cCtx.Int("n") 699 | j := cCtx.Bool("json") 700 | extra := cCtx.Bool("extra") 701 | 702 | cfg := cCtx.App.Metadata["config"].(*Config) 703 | 704 | // get followers 705 | var followsMap map[string]Profile 706 | var err error 707 | if j && !extra { 708 | followsMap = make(map[string]Profile) 709 | } else { 710 | followsMap, err = cfg.GetFollows(cCtx.String("a")) 711 | if err != nil { 712 | return err 713 | } 714 | } 715 | 716 | // get timeline 717 | filter := nostr.Filter{ 718 | Kinds: []int{nostr.KindTextNote}, 719 | Search: strings.Join(cCtx.Args().Slice(), " "), 720 | Limit: n, 721 | } 722 | 723 | evs := cfg.Events(filter) 724 | cfg.PrintEvents(evs, followsMap, j, extra) 725 | return nil 726 | } 727 | 728 | func doBroadcast(cCtx *cli.Context) error { 729 | id := cCtx.String("id") 730 | from := cCtx.String("relay") 731 | 732 | var filter nostr.Filter 733 | 734 | if evp := sdk.InputToEventPointer(id); evp == nil { 735 | epp := sdk.InputToProfile(context.Background(), id) 736 | if epp == nil { 737 | return fmt.Errorf("failed to parse note/npub from '%s'", id) 738 | } 739 | filter = nostr.Filter{ 740 | Kinds: []int{nostr.KindProfileMetadata}, 741 | Authors: []string{epp.PublicKey}, 742 | } 743 | } else { 744 | filter = nostr.Filter{ 745 | IDs: []string{evp.ID}, 746 | } 747 | } 748 | 749 | cfg := cCtx.App.Metadata["config"].(*Config) 750 | 751 | var ev *nostr.Event 752 | var mu sync.Mutex 753 | 754 | if from != "" { 755 | ctx := context.Background() 756 | relay, err := nostr.RelayConnect(ctx, from) 757 | if err != nil { 758 | return err 759 | } 760 | defer relay.Close() 761 | evs, err := relay.QuerySync(ctx, filter) 762 | if err != nil { 763 | return err 764 | } 765 | if len(evs) > 0 { 766 | ev = evs[0] 767 | } 768 | } else { 769 | cfg.Do(Relay{Read: true}, func(ctx context.Context, relay *nostr.Relay) bool { 770 | if relay.URL == from { 771 | return true 772 | } 773 | evs, err := relay.QuerySync(ctx, filter) 774 | if err != nil { 775 | return true 776 | } 777 | if len(evs) > 0 { 778 | mu.Lock() 779 | ev = evs[0] 780 | mu.Unlock() 781 | } 782 | return false 783 | }) 784 | } 785 | 786 | if ev == nil { 787 | return fmt.Errorf("failed to get event '%s'", id) 788 | } 789 | 790 | var success atomic.Int64 791 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 792 | err := relay.Publish(ctx, *ev) 793 | if err != nil { 794 | fmt.Fprintln(os.Stderr, relay.URL, err) 795 | } else { 796 | success.Add(1) 797 | } 798 | return true 799 | }) 800 | if success.Load() == 0 { 801 | return errors.New("cannot broadcast") 802 | } 803 | return nil 804 | } 805 | 806 | func doStream(cCtx *cli.Context) error { 807 | kinds := cCtx.IntSlice("kind") 808 | authors := cCtx.StringSlice("author") 809 | f := cCtx.Bool("follow") 810 | pattern := cCtx.String("pattern") 811 | reply := cCtx.String("reply") 812 | tags := cCtx.StringSlice("tag") 813 | 814 | var re *regexp.Regexp 815 | if pattern != "" { 816 | var err error 817 | re, err = regexp.Compile(pattern) 818 | if err != nil { 819 | return err 820 | } 821 | } 822 | 823 | cfg := cCtx.App.Metadata["config"].(*Config) 824 | 825 | relay := cfg.FindRelay(context.Background(), Relay{Read: true}) 826 | if relay == nil { 827 | return errors.New("cannot connect relays") 828 | } 829 | defer relay.Close() 830 | 831 | var sk string 832 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 833 | sk = s.(string) 834 | } else { 835 | return err 836 | } 837 | pub, err := nostr.GetPublicKey(sk) 838 | if err != nil { 839 | return err 840 | } 841 | 842 | // get followers 843 | var follows []string 844 | if f { 845 | followsMap, err := cfg.GetFollows(cCtx.String("a")) 846 | if err != nil { 847 | return err 848 | } 849 | for k := range followsMap { 850 | follows = append(follows, k) 851 | } 852 | } else { 853 | for _, author := range authors { 854 | if pp := sdk.InputToProfile(context.TODO(), author); pp != nil { 855 | follows = append(follows, pp.PublicKey) 856 | } else { 857 | return fmt.Errorf("failed to parse pubkey from '%s'", author) 858 | } 859 | } 860 | } 861 | 862 | since := nostr.Now() 863 | filter := nostr.Filter{ 864 | Kinds: kinds, 865 | Authors: follows, 866 | Since: &since, 867 | Tags: nostr.TagMap{}, 868 | } 869 | 870 | for _, tag := range tags { 871 | name, value, found := strings.Cut(tag, "=") 872 | tag := []string{} 873 | if found { 874 | // tags may also contain extra elements separated with a ";" 875 | tag = append(tag, strings.Split(value, ";")...) 876 | } 877 | filter.Tags[name] = tag 878 | } 879 | sub, err := relay.Subscribe(context.Background(), nostr.Filters{filter}) 880 | if err != nil { 881 | return err 882 | } 883 | 884 | if reply == "" { 885 | for ev := range sub.Events { 886 | json.NewEncoder(os.Stdout).Encode(ev) 887 | } 888 | } else { 889 | for ev := range sub.Events { 890 | if re != nil && !re.MatchString(ev.Content) { 891 | continue 892 | } 893 | var evr nostr.Event 894 | evr.PubKey = pub 895 | evr.Content = reply 896 | evr.Tags = nostr.Tags{} 897 | clientTag(&evr) 898 | for _, tag := range ev.Tags { 899 | if len(tag) > 0 && tag[0] != "e" && tag[0] != "p" { 900 | evr.Tags = evr.Tags.AppendUnique(tag) 901 | } 902 | } 903 | evr.Tags = evr.Tags.AppendUnique(nostr.Tag{"e", ev.ID, "", "reply"}) 904 | evr.CreatedAt = nostr.Now() 905 | evr.Kind = ev.Kind 906 | if err := evr.Sign(sk); err != nil { 907 | return err 908 | } 909 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 910 | relay.Publish(ctx, evr) 911 | return true 912 | }) 913 | } 914 | } 915 | 916 | return nil 917 | } 918 | 919 | func doTimeline(cCtx *cli.Context) error { 920 | u := cCtx.String("u") 921 | n := cCtx.Int("n") 922 | j := cCtx.Bool("json") 923 | extra := cCtx.Bool("extra") 924 | article := cCtx.Bool("article") 925 | 926 | cfg := cCtx.App.Metadata["config"].(*Config) 927 | 928 | // get followers 929 | followsMap, err := cfg.GetFollows(cCtx.String("a")) 930 | if err != nil { 931 | return err 932 | } 933 | var follows []string 934 | if u == "" { 935 | for k := range followsMap { 936 | follows = append(follows, k) 937 | } 938 | } else { 939 | if pp := sdk.InputToProfile(context.TODO(), u); pp != nil { 940 | u = pp.PublicKey 941 | } else { 942 | return fmt.Errorf("failed to parse pubkey from '%s'", u) 943 | } 944 | follows = []string{u} 945 | } 946 | 947 | kind := nostr.KindTextNote 948 | if article { 949 | kind = nostr.KindArticle 950 | } 951 | // get timeline 952 | filter := nostr.Filter{ 953 | Kinds: []int{kind}, 954 | Authors: follows, 955 | Limit: n, 956 | } 957 | 958 | evs := cfg.Events(filter) 959 | cfg.PrintEvents(evs, followsMap, j, extra) 960 | return nil 961 | } 962 | 963 | func postMsg(cCtx *cli.Context, msg string) error { 964 | cfg := cCtx.App.Metadata["config"].(*Config) 965 | 966 | var sk string 967 | if _, s, err := nip19.Decode(cfg.PrivateKey); err == nil { 968 | sk = s.(string) 969 | } else { 970 | return err 971 | } 972 | ev := nostr.Event{} 973 | if pub, err := nostr.GetPublicKey(sk); err == nil { 974 | if _, err := nip19.EncodePublicKey(pub); err != nil { 975 | return err 976 | } 977 | ev.PubKey = pub 978 | } else { 979 | return err 980 | } 981 | 982 | ev.Content = msg 983 | ev.CreatedAt = nostr.Now() 984 | ev.Kind = nostr.KindTextNote 985 | ev.Tags = nostr.Tags{} 986 | clientTag(&ev) 987 | if err := ev.Sign(sk); err != nil { 988 | return err 989 | } 990 | 991 | var success atomic.Int64 992 | cfg.Do(Relay{Write: true}, func(ctx context.Context, relay *nostr.Relay) bool { 993 | err := relay.Publish(ctx, ev) 994 | if err != nil { 995 | fmt.Fprintln(os.Stderr, relay.URL, err) 996 | } else { 997 | success.Add(1) 998 | } 999 | return true 1000 | }) 1001 | if success.Load() == 0 { 1002 | return errors.New("cannot post") 1003 | } 1004 | return nil 1005 | } 1006 | 1007 | func doPowa(cCtx *cli.Context) error { 1008 | return postMsg(cCtx, "ぽわ〜") 1009 | } 1010 | 1011 | func doPuru(cCtx *cli.Context) error { 1012 | return postMsg(cCtx, "(((( ˙꒳​˙ ))))プルプルプルプルプルプルプル") 1013 | } 1014 | -------------------------------------------------------------------------------- /zap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | 13 | "github.com/mdp/qrterminal/v3" 14 | "github.com/urfave/cli/v2" 15 | 16 | "github.com/nbd-wtf/go-nostr" 17 | "github.com/nbd-wtf/go-nostr/nip04" 18 | "github.com/nbd-wtf/go-nostr/nip19" 19 | ) 20 | 21 | // Lnurlp is 22 | type Lnurlp struct { 23 | Callback string `json:"callback"` 24 | MaxSendable int64 `json:"maxSendable"` 25 | MinSendable int `json:"minSendable"` 26 | Metadata string `json:"metadata"` 27 | CommentAllowed int `json:"commentAllowed"` 28 | Tag string `json:"tag"` 29 | AllowsNostr bool `json:"allowsNostr"` 30 | NostrPubkey string `json:"nostrPubkey"` 31 | } 32 | 33 | // Invoice is 34 | type Invoice struct { 35 | PR string `json:"pr"` 36 | } 37 | 38 | // PayRequest is 39 | type PayRequest struct { 40 | Method string `json:"method"` 41 | Params struct { 42 | Invoice string `json:"invoice"` 43 | Routes []string `json:"routes:"` 44 | } `json:"params"` 45 | } 46 | 47 | // PayResponse is 48 | type PayResponse struct { 49 | ResultType *string `json:"result_type"` 50 | Err *struct { 51 | Code string `json:"code"` 52 | Message string `json:"message"` 53 | } `json:"error"` 54 | Result *struct { 55 | Preimage string `json:"preimage"` 56 | } `json:"result"` 57 | } 58 | 59 | func pay(cfg *Config, invoice string) error { 60 | uri, err := url.Parse(cfg.NwcURI) 61 | if err != nil { 62 | return err 63 | } 64 | wallet := uri.Host 65 | host := uri.Query().Get("relay") 66 | secret := uri.Query().Get("secret") 67 | pub, err := nostr.GetPublicKey(secret) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | relay, err := nostr.RelayConnect(context.Background(), host) 73 | if err != nil { 74 | return err 75 | } 76 | defer relay.Close() 77 | 78 | ss, err := nip04.ComputeSharedSecret(wallet, secret) 79 | if err != nil { 80 | return err 81 | } 82 | var req PayRequest 83 | req.Method = "pay_invoice" 84 | req.Params.Invoice = invoice 85 | b, err := json.Marshal(req) 86 | if err != nil { 87 | return err 88 | } 89 | content, err := nip04.Encrypt(string(b), ss) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | ev := nostr.Event{ 95 | PubKey: pub, 96 | CreatedAt: nostr.Now(), 97 | Kind: nostr.KindNWCWalletRequest, 98 | Tags: nostr.Tags{nostr.Tag{"p", wallet}}, 99 | Content: content, 100 | } 101 | err = ev.Sign(secret) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | filters := []nostr.Filter{{ 107 | Tags: nostr.TagMap{ 108 | "p": []string{pub}, 109 | "e": []string{ev.ID}, 110 | }, 111 | Kinds: []int{nostr.KindNWCWalletInfo, nostr.KindNWCWalletResponse, nostr.KindNWCWalletRequest}, 112 | Limit: 1, 113 | }} 114 | sub, err := relay.Subscribe(context.Background(), filters) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | err = relay.Publish(context.Background(), ev) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | er := <-sub.Events 125 | content, err = nip04.Decrypt(er.Content, ss) 126 | if err != nil { 127 | return err 128 | } 129 | var resp PayResponse 130 | err = json.Unmarshal([]byte(content), &resp) 131 | if err != nil { 132 | return err 133 | } 134 | if resp.Err != nil { 135 | return fmt.Errorf(resp.Err.Message) 136 | } 137 | json.NewEncoder(os.Stdout).Encode(resp) 138 | return nil 139 | } 140 | 141 | // ZapInfo is 142 | func (cfg *Config) ZapInfo(pub string) (*Lnurlp, error) { 143 | relay := cfg.FindRelay(context.Background(), Relay{Read: true}) 144 | if relay == nil { 145 | return nil, errors.New("cannot connect relays") 146 | } 147 | defer relay.Close() 148 | 149 | // get set-metadata 150 | filter := nostr.Filter{ 151 | Kinds: []int{nostr.KindProfileMetadata}, 152 | Authors: []string{pub}, 153 | Limit: 1, 154 | } 155 | 156 | evs := cfg.Events(filter) 157 | if len(evs) == 0 { 158 | return nil, errors.New("cannot find user") 159 | } 160 | 161 | var profile Profile 162 | err := json.Unmarshal([]byte(evs[0].Content), &profile) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | tok := strings.SplitN(profile.Lud16, "@", 2) 168 | if err != nil { 169 | return nil, err 170 | } 171 | if len(tok) != 2 { 172 | return nil, errors.New("receipt address is not valid") 173 | } 174 | 175 | resp, err := http.Get("https://" + tok[1] + "/.well-known/lnurlp/" + tok[0]) 176 | if err != nil { 177 | return nil, err 178 | } 179 | defer resp.Body.Close() 180 | 181 | var lp Lnurlp 182 | err = json.NewDecoder(resp.Body).Decode(&lp) 183 | if err != nil { 184 | return nil, err 185 | } 186 | return &lp, nil 187 | } 188 | 189 | func doZap(cCtx *cli.Context) error { 190 | if cCtx.Args().Len() == 0 { 191 | return cli.ShowSubcommandHelp(cCtx) 192 | } 193 | return callZap(&zapArg{ 194 | cfg: cCtx.App.Metadata["config"].(*Config), 195 | amount: cCtx.Uint64("amount"), 196 | comment: cCtx.String("comment"), 197 | id: cCtx.Args().First(), 198 | }) 199 | } 200 | 201 | type zapArg struct { 202 | cfg *Config 203 | amount uint64 204 | comment string 205 | id string 206 | } 207 | 208 | func callZap(arg *zapArg) error { 209 | var sk string 210 | if _, s, err := nip19.Decode(arg.cfg.PrivateKey); err == nil { 211 | sk = s.(string) 212 | } else { 213 | return err 214 | } 215 | 216 | receipt := "" 217 | zr := nostr.Event{} 218 | zr.Tags = nostr.Tags{} 219 | clientTag(&zr) 220 | 221 | if pub, err := nostr.GetPublicKey(sk); err == nil { 222 | if _, err := nip19.EncodePublicKey(pub); err != nil { 223 | return err 224 | } 225 | zr.PubKey = pub 226 | } else { 227 | return err 228 | } 229 | 230 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"amount", fmt.Sprint(arg.amount * 1000)}) 231 | relays := nostr.Tag{"relays"} 232 | for k, v := range arg.cfg.Relays { 233 | if v.Write { 234 | relays = append(relays, k) 235 | } 236 | } 237 | zr.Tags = zr.Tags.AppendUnique(relays) 238 | if prefix, s, err := nip19.Decode(arg.id); err == nil { 239 | switch prefix { 240 | case "nevent": 241 | receipt = s.(nostr.EventPointer).Author 242 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"p", receipt}) 243 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"e", s.(nostr.EventPointer).ID}) 244 | case "note": 245 | evs := arg.cfg.Events(nostr.Filter{IDs: []string{s.(string)}}) 246 | if len(evs) != 0 { 247 | receipt = evs[0].PubKey 248 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"p", receipt}) 249 | } 250 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"e", s.(string)}) 251 | case "npub": 252 | receipt = s.(string) 253 | zr.Tags = zr.Tags.AppendUnique(nostr.Tag{"p", receipt}) 254 | default: 255 | return errors.New("invalid argument") 256 | } 257 | } 258 | 259 | zr.Kind = nostr.KindZapRequest // 9734 260 | zr.CreatedAt = nostr.Now() 261 | zr.Content = arg.comment 262 | if err := zr.Sign(sk); err != nil { 263 | return err 264 | } 265 | b, err := zr.MarshalJSON() 266 | if err != nil { 267 | return err 268 | } 269 | 270 | zi, err := arg.cfg.ZapInfo(receipt) 271 | if err != nil { 272 | return err 273 | } 274 | u, err := url.Parse(zi.Callback) 275 | if err != nil { 276 | return err 277 | } 278 | param := url.Values{} 279 | param.Set("amount", fmt.Sprint(arg.amount*1000)) 280 | param.Set("nostr", string(b)) 281 | u.RawQuery = param.Encode() 282 | resp, err := http.Get(u.String()) 283 | if err != nil { 284 | return err 285 | } 286 | defer resp.Body.Close() 287 | 288 | var iv Invoice 289 | err = json.NewDecoder(resp.Body).Decode(&iv) 290 | if err != nil { 291 | return err 292 | } 293 | 294 | if arg.cfg.NwcURI == "" { 295 | config := qrterminal.Config{ 296 | HalfBlocks: false, 297 | Level: qrterminal.L, 298 | Writer: os.Stdout, 299 | WhiteChar: qrterminal.WHITE, 300 | BlackChar: qrterminal.BLACK, 301 | QuietZone: 2, 302 | WithSixel: true, 303 | } 304 | fmt.Println("lightning:" + iv.PR) 305 | qrterminal.GenerateWithConfig("lightning:"+iv.PR, config) 306 | } else { 307 | pay(arg.cfg, iv.PR) 308 | } 309 | return nil 310 | } 311 | --------------------------------------------------------------------------------