Compare commits

...
44 Commits
Author SHA1 Message Date
fiatjaf ae98e99ec6 update nostrlib to fix stuck subscriptions. 2026-03-20 20:56:38 -03:00
Yasuhiro Matsumotoandfiatjaf_ 29db04b7ea fix: add missing target flag to nutzap command 2026-03-20 13:44:14 -03:00
fiatjaf 59fecacbdc ensure we're adding a "K" tag to kind:1111 events. 2026-03-17 20:48:53 -03:00
fiatjaf faac4d9440 ensure an event is not resigned if it was already signed and wasn't changed. 2026-03-17 19:32:47 -03:00
fiatjaf f00a4a7d2a fix potential double-yield() usage on reading lines from stdin. 2026-03-17 14:57:42 -03:00
fiatjaf d823ac55a6 fix nostrlib dependency. 2026-03-17 14:57:11 -03:00
fiatjaf 105ccef7ec gift: fix potential keys ordering and rumor decryption failure check. 2026-03-17 14:56:55 -03:00
fiatjaf 9d4df21836 gift/encrypt: fix wording and yield returns. 2026-03-17 11:49:09 -03:00
fiatjaf 952d638ac3 git: fix --owner flag in "init".
fixes https://github.com/fiatjaf/nak/issues/113
2026-03-16 16:09:00 -03:00
fiatjaf c1e72e0af1 convert tags to appropriate hex pubkeys, ids etc, even from nip05, but check the tag name first for better (hardcoded) context. 2026-03-13 00:30:46 -03:00
fiatjaf 5f4efdbc69 'group forum' with the ui like 'nak git issue'. 2026-03-12 19:03:20 -03:00
fiatjaf 4967db13a1 update nostrlib dependency. 2026-03-12 16:50:19 -03:00
fiatjaf da0b753371 group: edit-metadata as PUT, not PATCH. 2026-03-12 16:33:50 -03:00
fiatjaf bef67d35d2 groups: replace no-text with supported_kinds support. 2026-03-12 16:20:23 -03:00
fiatjaf a8fb2e4189 git: allow closing a patch as applied. 2026-03-12 16:19:58 -03:00
fiatjaf 7aea4cf9a1 git: ensure some operations are only completed if you're a maintainer. 2026-03-08 21:24:28 -03:00
fiatjaf d472efe707 git: issue and patch closing. 2026-03-08 21:06:34 -03:00
fiatjaf 17341b3af6 git: issue and patch listing unified in base command. 2026-03-08 21:02:52 -03:00
fiatjaf ff5a7b4ba7 git: issue replying in the middle of a thread. 2026-03-08 18:29:53 -03:00
mattnandGitHub 7596e317b8 Merge pull request #109 from fiatjaf/fix/group-forum-tag-filter
fix: remove incorrect '#' prefix from TagMap key in group forum filter
2026-03-08 11:24:24 +09:00
mattnandGitHub da7fc3fac3 Merge pull request #108 from mattn/fix/bunker-format-verbs
fix: use %s instead of %w in log/color.RedString format strings
2026-03-08 11:22:48 +09:00
mattnandGitHub 0e8475b388 Merge pull request #107 from mattn/fix/wallet-typo
fix typo
2026-03-08 11:19:04 +09:00
Yasuhiro Matsumoto 5532c884bc fix: use %s instead of %w in log/color.RedString format strings
%w is only valid in fmt.Errorf. When used in fmt.Sprintf (via
color.RedString) or log, it produces garbled output like
"%!w(MISSING)" and the actual error is not displayed.
2026-03-08 11:15:14 +09:00
Yasuhiro Matsumoto 05857ab190 fix: typo in wallet error message ("walle" -> "wallet") 2026-03-08 11:14:04 +09:00
Yasuhiro Matsumoto bf22a63404 fix: remove incorrect '#' prefix from TagMap key in group forum filter
The forum subcommand used "#h" as the TagMap key, but TagMap keys
should not include the '#' prefix. All other TagMap usages in this
file correctly use just the tag letter (e.g. "d", "h").
2026-03-08 11:13:42 +09:00
fiatjaf 6bd2d1cdfc git: issues and patches improved enormously. 2026-03-07 23:10:00 -03:00
fiatjaf 9c8d59c5c5 group: talk prints URL to meet.livekit.io 2026-03-07 23:10:00 -03:00
fiatjaf 5957c08d15 git: issues and patches. 2026-03-06 18:40:54 -03:00
fiatjaf f59c8a670d group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now). 2026-03-05 22:49:23 -03:00
fiatjaf 61a3b89d08 req: handle values from closeds better.
(it may be closed when all is ok, so we shouldn't rely on yielding an actual value)
2026-03-04 16:16:46 -03:00
fiatjaf d61fdc4cb4 git: fix first publishing of a new repository announcement. 2026-03-04 16:01:22 -03:00
fiatjaf 0735ded0fc update nostrlib to hopefully fix issues with zombie connections. 2026-03-04 00:10:20 -03:00
fiatjaf 5233a77510 nak profile 2026-02-23 20:56:21 -03:00
Alex Gleasonandfiatjaf_ fd34cc7c5e git clone: support NIP-05 names in nostr:// URIs
Resolve NIP-05 identifiers (e.g. chad@chadwick.site) in the owner
position of nostr:// URIs, so 'nak git clone nostr://user@domain/relay/id'
works without manually resolving to an npub first.
2026-02-20 23:11:36 -03:00
JannikStreekandGitHub 2d151c2ac8 Fix: docker build 2026-02-19 10:58:54 -03:00
fiatjaf 037e8efcc6 count: support reading filters from stdin like req. 2026-02-12 14:34:24 -03:00
fiatjaf 1b380dea9a count: support the same flags as req.
fixes https://github.com/fiatjaf/nak/issues/103
2026-02-12 14:25:57 -03:00
fiatjaf f126b3f7ee mcp: searching for pubkeys and nip05s returns them directly. 2026-02-06 13:52:15 -03:00
fiatjaf dc5ffe5129 bunker: fix authorizing pubkeys from --authorized-secrets.
closes https://github.com/fiatjaf/nak/issues/102
2026-02-04 23:26:26 -03:00
Yasuhiro Matsumotoandfiatjaf_ 7637b5018f avoid panic when mint URL has no '://' 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumotoandfiatjaf_ d5ab34bb2f remove redundant if err != nil in main 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumotoandfiatjaf_ 49345333c4 check dateparser error before using date.Time 2026-02-01 14:24:51 -03:00
Yasuhiro Matsumotoandfiatjaf_ b5de7b78bc prevent panic on AUTH challenge tag (nil or len<2) 2026-02-01 14:24:51 -03:00
fiatjaf ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
22 changed files with 2275 additions and 310 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
# build stage
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
# install git and ca-certificates (needed for fetching dependencies)
RUN apk add --no-cache git ca-certificates
RUN apk add --no-cache git ca-certificates gcc musl-dev
# set working directory
WORKDIR /app
@@ -19,7 +19,7 @@ COPY . .
# build the application
# use cgo_enabled=0 to create a static binary
# use -ldflags to strip debug info and reduce binary size
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o nak .
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o nak .
# runtime stage
FROM alpine:latest
+2 -2
View File
@@ -6,14 +6,14 @@ install with this one-liner:
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
- or install with `go install github.com/fiatjaf/nak@latest` if you have [Go](https://pkg.go.dev) set up.
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install ripgrep` if you use **Nix**.
- or install with `nix-env --install nak` if you use **Nix**.
## what can you do with it?
+13 -6
View File
@@ -117,16 +117,16 @@ var bunker = &cli.Command{
persist = func() {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
log(color.RedString("failed to persist: %w\n"), err)
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
log(color.RedString("failed to persist: %w\n"), err)
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
if err := os.WriteFile(path, data, 0600); err != nil {
log(color.RedString("failed to persist: %w\n"), err)
log(color.RedString("failed to persist: %s\n"), err)
os.Exit(4)
}
}
@@ -418,6 +418,13 @@ var bunker = &cli.Command{
return true
}
if slices.Contains(authorizedSecrets, secret) {
// add client to authorized list for subsequent requests
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
if persist != nil {
persist()
}
}
return true
}
@@ -623,21 +630,21 @@ func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %w\n", err))
log(color.RedString("failed to create socket directory: %s\n", err))
return res
}
// delete existing socket file if it exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log(color.RedString("failed to remove existing socket file: %w\n", err))
log(color.RedString("failed to remove existing socket file: %s\n", err))
return res
}
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket %s: %w\n", socketPath, err))
log(color.RedString("failed to listen on unix socket %s: %s\n", socketPath, err))
return res
}
+52 -131
View File
@@ -9,63 +9,17 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip45"
"fiatjaf.com/nostr/nip45/hyperloglog"
"github.com/mailru/easyjson"
"github.com/urfave/cli/v3"
)
var count = &cli.Command{
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
Description: `like 'nak req', but does a "COUNT" call instead. Will attempt to perform HyperLogLog aggregation if more than one relay is specified.`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&PubKeySliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
},
ArgsUsage: "[relay...]",
Flags: reqFilterFlags,
ArgsUsage: "[relay...]",
Action: func(ctx context.Context, c *cli.Command) error {
biggerUrlSize := 0
relayUrls := c.Args().Slice()
@@ -84,95 +38,62 @@ var count = &cli.Command{
}
}
filter := nostr.Filter{}
if authors := getPubKeySlice(c, "author"); len(authors) > 0 {
filter.Authors = authors
}
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]nostr.Kind, len(kinds64))
for i, v := range kinds64 {
kinds[i] = nostr.Kind(v)
}
filter.Kinds = kinds
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", decodeTagValue(etag)})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", decodeTagValue(ptag)})
}
if len(tags) > 0 {
filter.Tags = make(nostr.TagMap)
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
}
if c.IsSet("since") {
filter.Since = getNaturalDate(c, "since")
}
if c.IsSet("until") {
filter.Until = getNaturalDate(c, "until")
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = int(limit)
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
// go line by line from stdin or run once with input from flags
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " 📋"
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
successes := 0
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, filter, nostr.SubscriptionOptions{
Label: "nak-count",
})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
if err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
continue
}
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " (hll)"
}
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
if successes == 0 {
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
stdout(result)
}
exitIfLineProcessingError(ctx)
return nil
},
}
+1 -1
View File
@@ -123,7 +123,7 @@ var decrypt = &cli.Command{
res, err := kr.Decrypt(ctx, ciphertext, source)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
return fmt.Errorf("failed to decrypt: %w", err)
}
stdout(res)
}
+16 -7
View File
@@ -66,6 +66,12 @@ example:
Hidden: true,
},
// ~~~
&cli.BoolFlag{
Name: "force-sign",
Usage: "when an event is already signed and not modified it isn't signed again even when a different --sec is given, this option negates that",
Value: false,
Category: CATEGORY_SIGNER,
},
&cli.UintFlag{
Name: "pow",
Usage: "nip13 difficulty to target when doing hash work on the event id",
@@ -212,8 +218,12 @@ example:
if found {
// tags may also contain extra elements separated with a ";"
tagValues := strings.Split(tagValue, ";")
val := tagValues[0]
if len(tagName) == 1 {
val = decodeTagValue(val, rune(tagName[0]))
}
if len(tagValues) >= 1 {
tagValues[0] = decodeTagValue(tagValues[0])
tagValues[0] = val
}
tag = append(tag, tagValues...)
}
@@ -221,21 +231,20 @@ example:
}
for _, etag := range c.StringSlice("e") {
decodedEtag := decodeTagValue(etag)
decodedEtag := decodeTagValue(etag, 'e')
if tags.FindWithValue("e", decodedEtag) == nil {
tags = append(tags, nostr.Tag{"e", decodedEtag})
}
}
for _, ptag := range c.StringSlice("p") {
decodedPtag := decodeTagValue(ptag)
decodedPtag := decodeTagValue(ptag, 'p')
if tags.FindWithValue("p", decodedPtag) == nil {
tags = append(tags, nostr.Tag{"p", decodedPtag})
}
}
for _, dtag := range c.StringSlice("d") {
decodedDtag := decodeTagValue(dtag)
if tags.FindWithValue("d", decodedDtag) == nil {
tags = append(tags, nostr.Tag{"d", decodedDtag})
if tags.FindWithValue("d", dtag) == nil {
tags = append(tags, nostr.Tag{"d", dtag})
}
}
if len(tags) > 0 {
@@ -253,7 +262,7 @@ example:
mustRehashAndResign = true
}
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
if c.IsSet("musig") || c.Bool("force-sign") {
mustRehashAndResign = true
}
+1 -1
View File
@@ -105,10 +105,10 @@ func (t *naturalTimeValue) Set(value string) error {
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
ts = date.Time
if err != nil {
return err
}
ts = date.Time
}
if t.timestamp != nil {
+14 -8
View File
@@ -179,7 +179,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
if has {
if err != nil {
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
return fmt.Errorf("receiver's decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
}
ciphers = append(ciphers, kr)
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
@@ -209,9 +209,12 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
for c, potentialCipher := range ciphers {
switch c {
case 0:
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
log("- trying receiver's identity key %s\n", color.CyanString(receiver.Hex()))
case 1:
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
if eSec.Public() == nostr.ZeroPK {
continue
}
log("- trying receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
}
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
@@ -227,7 +230,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
cipher = potentialCipher
break
}
if seal.ID == nostr.ZeroID {
if seal.ID == nostr.ZeroID && seal.PubKey == nostr.ZeroPK && seal.CreatedAt == 0 {
// if both ciphers failed above we'll reach here
return fmt.Errorf("failed to decrypt seal: %w", err)
}
@@ -247,11 +250,15 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
var rumor nostr.Event
err = nil
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
if senderEncryptionPublicKey == nostr.ZeroPK {
continue
}
switch s {
case 0:
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
log("- trying sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
case 1:
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
log("- trying sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
}
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
@@ -267,7 +274,7 @@ a decoupled key (if it has been created or received with "nak dekey" previously)
break
}
if rumor.ID == nostr.ZeroID {
if rumor.ID == nostr.ZeroID && rumor.PubKey == nostr.ZeroPK && rumor.CreatedAt == 0 {
return fmt.Errorf("failed to decrypt rumor: %w", err)
}
@@ -321,7 +328,6 @@ func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pub
if eSec.Public() != ePub {
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
}
return eSec, true, nil
}
}
+987 -19
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -29,7 +29,7 @@ require (
)
require (
fiatjaf.com/lib v0.3.2
fiatjaf.com/lib v0.3.6
github.com/hanwen/go-fuse/v2 v2.9.0
)
+4 -4
View File
@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817 h1:Zp6rPetvwYFOLD+36RtmWmns2C0CLbtphD3DLu3cxCo=
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a h1:lor1LcOjMUNZi5hafyXMmTz5J2kTrvS5I0hZMy3jOuU=
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
+561 -44
View File
@@ -2,12 +2,19 @@ package main
import (
"context"
"encoding/base64"
stdjson "encoding/json"
"fmt"
"io"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip29"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
@@ -33,15 +40,9 @@ var group = &cli.Command{
return err
}
group := nip29.Group{}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
return err
}
break
group, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
@@ -68,6 +69,24 @@ var group = &cli.Command{
", "+
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
)
stdout("livekit:",
color.HiBlueString("%s", cond(group.LiveKit, "yes", "no"))+
", "+
cond(group.LiveKit, "group supports live audio/video with livekit", "group has no advertised live audio/video support"),
)
supportedKinds := "unspecified"
if group.SupportedKinds != nil {
if len(group.SupportedKinds) == 0 {
supportedKinds = "none"
} else {
kinds := make([]string, 0, len(group.SupportedKinds))
for _, kind := range group.SupportedKinds {
kinds = append(kinds, strconv.Itoa(int(kind)))
}
supportedKinds = strings.Join(kinds, ", ")
}
}
stdout("supported-kinds:", color.HiBlueString(supportedKinds))
return nil
},
},
@@ -234,7 +253,6 @@ var group = &cli.Command{
if err != nil {
return err
}
defer sub.Close()
eosed := false
messages := make([]struct {
@@ -329,9 +347,9 @@ var group = &cli.Command{
},
},
{
Name: "forum",
Usage: "read group forum posts",
Description: "access group forum functionality.",
Name: "talk",
Usage: "get livekit connection details",
Description: "requests a livekit jwt for this group and prints the livekit server url.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
@@ -339,25 +357,261 @@ var group = &cli.Command{
return err
}
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{11},
Tags: nostr.TagMap{"#h": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
title := evt.Tags.Find("title")
if title != nil {
stdout(colors.bold(title[1]))
} else {
stdout(colors.bold("<untitled>"))
}
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
stdout(evt.Content)
group, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
if !group.LiveKit {
return fmt.Errorf("group doesn't advertise livekit support")
}
// TODO: see what to do about this
serverURL, jwt, err := requestLivekitJWT(ctx, c, relay, identifier)
if err != nil {
return err
}
stdout("livekit:", color.HiBlueString(serverURL))
stdout("jwt:", color.HiBlueString(jwt))
stdout("join:", color.HiBlueString(
fmt.Sprintf("https://meet.livekit.io/custom?liveKitUrl=%s&token=%s", serverURL, jwt)),
)
return nil
},
},
{
Name: "forum",
Usage: "forum topic operations",
Description: "when called directly, lists forum topics; with an id prefix, displays that topic with threaded comments.",
ArgsUsage: "<relay>'<identifier> [id-prefix]",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
if err != nil {
return err
}
prefix := strings.TrimSpace(c.Args().Get(1))
if prefix == "" {
if len(topics) == 0 {
log("no forum topics found\n")
return nil
}
wg := sync.WaitGroup{}
for _, evt := range topics {
wg.Go(func() {
sys.FetchProfileMetadata(ctx, evt.PubKey)
})
}
wg.Wait()
for _, evt := range topics {
id := evt.ID.Hex()
date := evt.CreatedAt.Time().Format(time.DateOnly)
author := authorPreview(ctx, evt.PubKey)
subject := forumSubjectPreview(evt, 72)
if subject == "" {
subject = "<untitled>"
}
stdout(color.CyanString(id[:6]), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject))
}
return nil
}
evt, err := findEventByPrefix(topics, prefix)
if err != nil {
return err
}
return showThreadWithComments(ctx, []string{relay}, evt, "", nostr.TagMap{"h": []string{identifier}})
},
Commands: []*cli.Command{
{
Name: "create",
Usage: "edit and send a forum topic event (kind 11)",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
groupName := groupMeta.Name
if groupName == "" {
groupName = identifier
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return fmt.Errorf("failed to gather keyer: %w", err)
}
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
if err != nil {
return fmt.Errorf("failed to get current identity: %w", err)
}
content, err := editWithDefaultEditor(
"nak-group-forum/NOTES_EDITMSG",
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
# creating forum topic in group '%s' ('%s''%s')
# the first line will be used as the topic title
topic title here
# the remaining lines will be the body
write your forum post
# lines starting with '#' are ignored
`, selfName, selfNpub, groupName, relay, identifier)),
true,
)
if err != nil {
return err
}
title, body, err := parseForumCreateContent(content)
if err != nil {
return err
}
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 11,
Tags: nostr.Tags{
nostr.Tag{"h", identifier},
nostr.Tag{"title", title},
},
Content: body,
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign forum topic event: %w", err)
}
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
},
},
{
Name: "comment",
Usage: "comment on a forum topic with a NIP-22 comment event",
ArgsUsage: "<relay>'<identifier> <id-prefix>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
prefix := strings.TrimSpace(c.Args().Get(1))
if prefix == "" {
return fmt.Errorf("missing forum topic id prefix")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return fmt.Errorf("failed to gather keyer: %w", err)
}
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
if err != nil {
return fmt.Errorf("failed to get current identity: %w", err)
}
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
if err != nil {
return err
}
topic, err := findEventByPrefix(topics, prefix)
if err != nil {
return err
}
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
groupName := groupMeta.Name
if groupName == "" {
groupName = identifier
}
subject := forumSubjectPreview(topic, 72)
if subject == "" {
subject = "<untitled>"
}
pm := sys.FetchProfileMetadata(ctx, topic.PubKey)
headerLines := []string{
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
fmt.Sprintf("commenting on forum topic '%s' '%s' by '%s' ('%s') in group '%s' ('%s''%s')", topic.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), groupName, relay, identifier),
}
comments, err := fetchThreadComments(ctx, []string{relay}, topic.ID, nostr.TagMap{"h": []string{identifier}})
if err != nil {
return err
}
edited, err := editWithDefaultEditor(
"nak-group-forum-reply/NOTES_EDITMSG",
threadReplyEditorTemplate(ctx, headerLines, topic, comments),
true,
)
if err != nil {
return err
}
content, parentEvt, err := parseThreadReplyContent(topic, comments, edited)
if err != nil {
return err
}
rootRelay := relay
if topic.Relay.URL != "" {
rootRelay = topic.Relay.URL
}
parentRelay := rootRelay
if parentEvt.Relay.URL != "" {
parentRelay = parentEvt.Relay.URL
}
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 1111,
Tags: nostr.Tags{
nostr.Tag{"E", topic.ID.Hex(), rootRelay},
nostr.Tag{"e", parentEvt.ID.Hex(), parentRelay},
nostr.Tag{"P", topic.PubKey.Hex()},
nostr.Tag{"p", parentEvt.PubKey.Hex()},
nostr.Tag{"h", identifier},
nostr.Tag{"K", strconv.Itoa(int(topic.Kind))},
},
Content: content,
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign forum comment event: %w", err)
}
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
},
},
},
},
{
Name: "put-user",
Usage: "add a user to the group with optional roles",
@@ -437,38 +691,123 @@ var group = &cli.Command{
&cli.BoolFlag{
Name: "public",
},
&cli.BoolFlag{
Name: "livekit",
},
&cli.BoolFlag{
Name: "no-livekit",
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"supported-kinds"},
Usage: "list of event kind numbers supported by this group",
},
&cli.BoolFlag{
Name: "all-kinds",
Usage: "specify this to delete the supported_kinds property, meaning everything will be supported",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
if c.Bool("livekit") || c.Bool("no-livekit") {
if err := checkRelayLivekitMetadataSupport(ctx, relay); err != nil {
return err
}
}
group, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
if group.Name == "" {
group.Name = identifier
}
if name := c.String("name"); name != "" {
group.Name = name
}
if picture := c.String("picture"); picture != "" {
group.Picture = picture
}
if about := c.String("about"); about != "" {
group.About = about
}
if c.Bool("restricted") {
group.Restricted = true
} else if c.Bool("unrestricted") {
group.Restricted = false
}
if c.Bool("closed") {
group.Closed = true
} else if c.Bool("open") {
group.Closed = false
}
if c.Bool("hidden") {
group.Hidden = true
} else if c.Bool("visible") {
group.Hidden = false
}
if c.Bool("private") {
group.Private = true
} else if c.Bool("public") {
group.Private = false
}
if c.Bool("livekit") {
group.LiveKit = true
} else if c.Bool("no-livekit") {
group.LiveKit = false
}
if supportedKinds := c.IntSlice("kind"); len(supportedKinds) > 0 {
kinds := make([]nostr.Kind, 0, len(supportedKinds))
for _, kind := range supportedKinds {
kinds = append(kinds, nostr.Kind(kind))
}
group.SupportedKinds = kinds
} else if c.Bool("all-kinds") {
group.SupportedKinds = nil
}
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
if name := c.String("name"); name != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
}
if picture := c.String("picture"); picture != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
}
if about := c.String("about"); about != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
}
if c.Bool("restricted") {
evt.Tags = append(evt.Tags, nostr.Tag{"name", group.Name})
evt.Tags = append(evt.Tags, nostr.Tag{"picture", group.Picture})
evt.Tags = append(evt.Tags, nostr.Tag{"about", group.About})
if group.Restricted {
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
} else if c.Bool("unrestricted") {
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
}
if c.Bool("closed") {
if group.Closed {
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
} else if c.Bool("open") {
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
}
if c.Bool("hidden") {
if group.Hidden {
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
} else if c.Bool("visible") {
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
}
if c.Bool("private") {
if group.Private {
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
} else if c.Bool("public") {
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
}
if group.LiveKit {
evt.Tags = append(evt.Tags, nostr.Tag{"livekit"})
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"no-livekit"})
}
if group.SupportedKinds != nil {
tag := make(nostr.Tag, 1, 1+len(group.SupportedKinds))
tag[0] = "supported_kinds"
for _, kind := range group.SupportedKinds {
tag = append(tag, strconv.Itoa(int(kind)))
}
evt.Tags = append(evt.Tags, tag)
}
return nil
})
},
@@ -584,3 +923,181 @@ func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
}
func fetchGroupMetadata(ctx context.Context, relay string, identifier string) (nip29.Group, error) {
group := nip29.Group{}
filter := nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
Tags: nostr.TagMap{"d": []string{identifier}},
Limit: 1,
}
if info, err := nip11.Fetch(ctx, relay); err == nil {
if info.Self != nil {
filter.Authors = append(filter.Authors, *info.Self)
} else if info.PubKey != nil {
filter.Authors = append(filter.Authors, *info.PubKey)
}
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
return group, err
}
break
}
return group, nil
}
func fetchGroupForumTopics(ctx context.Context, relay string, identifier string) ([]nostr.RelayEvent, error) {
topics := make([]nostr.RelayEvent, 0, 30)
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{11},
Tags: nostr.TagMap{"h": []string{identifier}},
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
topics = append(topics, ie)
}
slices.SortFunc(topics, nostr.CompareRelayEvent)
return topics, nil
}
func forumSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
if tag := evt.Tags.Find("title"); len(tag) >= 2 {
subject := strings.TrimSpace(tag[1])
if subject != "" {
return clampWithEllipsis(subject, maxChars)
}
}
if tag := evt.Tags.Find("subject"); len(tag) >= 2 {
subject := strings.TrimSpace(tag[1])
if subject != "" {
return clampWithEllipsis(subject, maxChars)
}
}
for _, line := range strings.Split(evt.Content, "\n") {
line = strings.TrimSpace(line)
if line != "" {
return clampWithEllipsis(line, maxChars)
}
}
return ""
}
func parseForumCreateContent(content string) (title string, body string, err error) {
lines := strings.Split(content, "\n")
var bodyb strings.Builder
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
if title == "" {
title = line
continue
}
bodyb.WriteString(line)
bodyb.WriteByte('\n')
}
if title == "" {
return "", "", fmt.Errorf("topic title cannot be empty")
}
body = strings.TrimSpace(bodyb.String())
return title, body, nil
}
func checkRelayLivekitMetadataSupport(ctx context.Context, relay string) error {
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create livekit support request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to check relay livekit support: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("relay doesn't advertise livekit support at %s (expected 204, got %d)", url, resp.StatusCode)
}
return nil
}
func requestLivekitJWT(ctx context.Context, c *cli.Command, relay string, identifier string) (serverURL string, jwt string, err error) {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return "", "", err
}
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit/" + identifier
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create livekit token request: %w", err)
}
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", url},
{"method", "GET"},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return "", "", fmt.Errorf("failed to sign livekit auth token: %w", err)
}
evtj, _ := stdjson.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("livekit token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed reading livekit token response: %w", err)
}
if resp.StatusCode >= 300 {
msg := strings.TrimSpace(string(body))
if msg != "" {
return "", "", fmt.Errorf("livekit token request failed with status %d: %s", resp.StatusCode, msg)
}
return "", "", fmt.Errorf("livekit token request failed with status %d", resp.StatusCode)
}
response := struct {
ServerURL string `json:"server_url"`
ParticipantToken string `json:"participant_token"`
}{}
if err := stdjson.Unmarshal(body, &response); err != nil {
return "", "", fmt.Errorf("invalid livekit token response: %w", err)
}
serverURL = response.ServerURL
jwt = response.ParticipantToken
if serverURL == "" || jwt == "" {
return "", "", fmt.Errorf("livekit token response missing url or jwt")
}
return serverURL, jwt, nil
}
+102 -5
View File
@@ -11,11 +11,14 @@ import (
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"unicode"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
@@ -61,6 +64,7 @@ func getJsonsOrBlank() iter.Seq[string] {
var finalJsonErr error
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
@@ -75,6 +79,7 @@ func getJsonsOrBlank() iter.Seq[string] {
finalJsonErr = nil
if !yield(stdinEvent) {
stopped = true
return false
}
@@ -82,8 +87,14 @@ func getJsonsOrBlank() iter.Seq[string] {
return true
})
if stopped {
return
}
if !hasStdin {
yield("{}")
if !yield("{}") {
return
}
}
if finalJsonErr != nil {
@@ -94,15 +105,23 @@ func getJsonsOrBlank() iter.Seq[string] {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
stopped := false
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
stopped = true
return false
}
return true
})
if stopped {
return
}
if !hasStdin {
yield("")
if !yield("") {
return
}
}
}
}
@@ -261,7 +280,7 @@ func connectToSingleRelay(
for range 5 {
if err := relay.Auth(ctx, func(ctx context.Context, authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.Find("challenge")
if challengeTag[1] == "" {
if challengeTag == nil || len(challengeTag) < 2 || challengeTag[1] == "" {
return fmt.Errorf("auth not received yet *****") // what a giant hack
}
return preAuthSigner(ctx, c, logthis, authEvent)
@@ -526,8 +545,23 @@ func parseEventID(value string) (nostr.ID, error) {
return nostr.ID{}, fmt.Errorf("invalid event id (\"%s\"): expected hex, note, or nevent", value)
}
func decodeTagValue(value string) string {
if strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1") || strings.HasPrefix(value, "nprofile1") || strings.HasPrefix(value, "naddr1") {
func decodeTagValue(value string, letter rune) string {
letter = unicode.ToLower(letter)
if letter == 'p' {
if nip05.IsValidIdentifier(value) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pp, err := nip05.QueryIdentifier(ctx, value)
cancel()
if err == nil {
return pp.PublicKey.Hex()
}
}
}
if (letter == 'p' && (strings.HasPrefix(value, "npub1") || strings.HasPrefix(value, "nprofile1"))) ||
((letter == 'a' || letter == 'q') && strings.HasPrefix(value, "naddr1")) ||
((letter == 'e' || letter == 'q') && (strings.HasPrefix(value, "nevent1") || strings.HasPrefix(value, "note1"))) {
if ptr, err := nip19.ToPointer(value); err == nil {
return ptr.AsTagReference()
}
@@ -535,6 +569,69 @@ func decodeTagValue(value string) string {
return value
}
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
fullpath := filepath.Join(os.TempDir(), filename)
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
if wipe {
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to remove temp file: %w", err)
}
}
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := tmp.WriteString(initialContent); err != nil {
tmp.Close()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("failed to close temp file: %w", err)
}
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
editor = "edit"
}
parts := strings.Fields(editor)
if len(parts) == 0 {
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
}
args := append(parts[1:], tmp.Name())
cmd := exec.Command(parts[0], args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("editor command failed: %w", err)
}
data, err := os.ReadFile(tmp.Name())
if err != nil {
return "", fmt.Errorf("failed to read edited temp file: %w", err)
}
return string(data), nil
}
func clampWithEllipsis(s string, size int) string {
if len(s) <= size {
return s
}
return s[0:size-1] + "…"
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
+18 -18
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -e
# Detect OS
# detect OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux";;
@@ -9,65 +9,65 @@ detect_os() {
FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";;
*)
echo "Error: Unsupported OS $(uname -s)" >&2
echo "error: unsupported OS $(uname -s)" >&2
exit 1
;;
esac
}
# Detect architecture
# detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";;
*)
echo "Error: Unsupported architecture $(uname -m)" >&2
echo "error: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
}
# Set install directory
# set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Detect platform
# detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
echo "Installing nak ($OS-$ARCH) to $INSTALL_DIR..."
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# Check if curl is available
command -v curl >/dev/null 2>&1 || { echo "Error: curl is required" >&2; exit 1; }
# check if curl is available
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# Get latest release tag
# get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}"
TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "Error: Failed to fetch release info" >&2; exit 1; }
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# Construct download URL
# construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# Create install directory and download
# create install directory and download
mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH"
echo "Installed nak $TAG to $TARGET_PATH"
# Check if install dir is in PATH
echo "installed nak $TAG to $TARGET_PATH"
# check if install dir is in PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "Note: Add $INSTALL_DIR to your PATH" ;;
*) echo "note: add $INSTALL_DIR to your PATH" ;;
esac
else
echo "Error: Download failed from $DOWNLOAD_URL" >&2
echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1
fi
+3 -6
View File
@@ -54,6 +54,7 @@ var app = &cli.Command{
nip,
syncCmd,
spell,
profile,
},
Version: version,
Flags: []cli.Flag{
@@ -127,9 +128,7 @@ func main() {
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
if err != nil {
log(color.YellowString(err.Error()) + "\n")
}
log(color.YellowString(err.Error()) + "\n")
colors.reset()
os.Exit(1)
}
@@ -137,9 +136,7 @@ func main() {
}
if err := app.Run(context.Background(), os.Args); err != nil {
if err != nil {
log("%s\n", color.RedString(err.Error()))
}
log("%s\n", color.RedString(err.Error()))
colors.reset()
os.Exit(1)
}
+29 -18
View File
@@ -158,29 +158,40 @@ var mcpServer = &cli.Command{
name := required[string](r, "name")
limit, _ := optional[float64](r, "limit")
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
res := strings.Builder{}
res.Grow(500)
res.WriteString("search results: ")
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) {
break
// check if input is already a valid pubkey
if pubkey, err := nostr.PubKeyFromHex(name); err == nil {
pm := sys.FetchProfileMetadata(ctx, pubkey)
res.WriteString(fmt.Sprintf("\n\nResult 1\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
pm.ShortName(), pm.PubKey.Hex(), pm.About))
} else {
// otherwise try to search
filter := nostr.Filter{Search: name, Kinds: []nostr.Kind{0}}
if limit > 0 {
filter.Limit = int(limit)
}
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine", "search.nos.social"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
pm, _ := sdk.ParseMetadata(result.Event)
res.WriteString(fmt.Sprintf("\n\nResult %d\nUser name: \"%s\"\nPublic key: \"%s\"\nDescription: \"%s\"\n",
l, pm.ShortName(), pm.PubKey.Hex(), pm.About))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
return mcp.NewToolResultText(res.String()), nil
})
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"context"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var profile = &cli.Command{
Name: "profile",
Usage: "displays profile information for a given pubkey",
Description: `fetches and displays profile metadata, relays, and contact count for a given pubkey.
example usage:
nak profile npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps
nak profile user@example.com`,
ArgsUsage: "[pubkey]",
Action: func(ctx context.Context, c *cli.Command) error {
for pubkeyInput := range getStdinLinesOrArguments(c.Args()) {
pk, err := parsePubKey(pubkeyInput)
if err != nil {
ctx = lineProcessingError(ctx, "invalid pubkey '%s': %s", pubkeyInput, err)
continue
}
pm := sys.FetchProfileMetadata(ctx, pk)
npub := nip19.EncodeNpub(pk)
stdout(colors.bold("pubkey (hex):"), pk.Hex())
stdout(colors.bold("npub:"), color.HiCyanString(npub))
relayList := sys.FetchRelayList(ctx, pk)
writeRelays := make([]string, 0, 3)
for _, rl := range relayList.Items {
if rl.Outbox {
writeRelays = append(writeRelays, rl.URL)
if len(writeRelays) == 3 {
break
}
}
}
if len(writeRelays) > 0 {
nprofile := nip19.EncodeNprofile(pk, writeRelays)
stdout(colors.bold("profile uri:"), color.HiCyanString("nostr:"+nprofile))
}
if pm.Name != "" {
stdout(colors.bold("name:"), color.HiBlueString(pm.Name))
}
if pm.DisplayName != "" {
stdout(colors.bold("display_name:"), color.HiBlueString(pm.DisplayName))
}
if pm.About != "" {
stdout(colors.bold("about:"), color.HiBlueString(pm.About))
}
if pm.Picture != "" {
stdout(colors.bold("picture:"), color.HiBlueString(pm.Picture))
}
if pm.Banner != "" {
stdout(colors.bold("banner:"), color.HiBlueString(pm.Banner))
}
if pm.Website != "" {
stdout(colors.bold("website:"), color.HiBlueString(pm.Website))
}
if pm.NIP05 != "" {
isValid := pm.NIP05Valid(ctx)
if isValid {
stdout(colors.bold("nip05:"), color.HiGreenString(pm.NIP05), color.HiGreenString("(verified)"))
} else {
stdout(colors.bold("nip05:"), color.HiRedString(pm.NIP05), color.HiRedString("(not verified)"))
}
}
if pm.LUD16 != "" {
stdout(colors.bold("lud16:"), color.HiBlueString(pm.LUD16))
}
if len(relayList.Items) > 0 {
stdout(colors.bold("relays:"))
for _, relay := range relayList.Items {
access := ""
if relay.Inbox && relay.Outbox {
access = "read/write"
} else if relay.Inbox {
access = "read"
} else if relay.Outbox {
access = "write"
}
stdout(" ", color.HiBlueString(relay.URL), color.HiCyanString("(%s)", access))
}
}
followList := sys.FetchFollowList(ctx, pk)
contactCount := len(followList.Items)
stdout(colors.bold("follows:"), color.HiCyanString("%d", contactCount))
}
exitIfLineProcessingError(ctx)
return nil
},
}
+1
View File
@@ -109,6 +109,7 @@ example:
if replyEvent.Kind != 1 {
evt.Kind = 1111
evt.Tags = append(evt.Tags, nostr.Tag{"K", fmt.Sprint(replyEvent.Kind)})
}
// add reply tags
+17 -11
View File
@@ -367,16 +367,18 @@ func performReq(
readevents:
for {
select {
case ie, ok := <-results:
if !ok {
case ie, stillOpen := <-results:
if !stillOpen {
break readevents
}
stdout(ie.Event)
case closed := <-closeds:
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
case closed, stillOpen := <-closeds:
if stillOpen {
if closed.HandledAuth {
logverbose("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
} else {
log("%s CLOSED: %s\n", closed.Relay.URL, closed.Reason)
}
}
case <-ctx.Done():
break readevents
@@ -466,19 +468,23 @@ func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, []string{spl[0], decodeTagValue(spl[1])})
val := spl[1]
if len(spl) == 1 {
val = decodeTagValue(val, []rune(spl[0])[0])
}
tags = append(tags, []string{spl[0], val})
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", decodeTagValue(etag)})
tags = append(tags, []string{"e", decodeTagValue(etag, 'e')})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", decodeTagValue(ptag)})
tags = append(tags, []string{"p", decodeTagValue(ptag, 'p')})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", decodeTagValue(dtag)})
tags = append(tags, []string{"d", dtag})
}
if len(tags) > 0 && filter.Tags == nil {
+4 -17
View File
@@ -82,19 +82,13 @@ var spell = &cli.Command{
displayName := entry.Name
if displayName == "" {
displayName = entry.Content
if len(displayName) > 28 {
displayName = displayName[:27] + "…"
}
displayName = clampWithEllipsis(entry.Content, 28)
}
if displayName != "" {
displayName = color.HiMagentaString(displayName) + ": "
}
desc := entry.Content
if len(desc) > 50 {
desc = desc[0:49] + "…"
}
desc := clampWithEllipsis(entry.Content, 50)
lastUsed := entry.LastUsed.Format("2006-01-02 15:04")
stdout(fmt.Sprintf(" %s %s%s - %s",
@@ -448,20 +442,13 @@ func logSpellDetails(spell nostr.Event) {
nameTag := spell.Tags.Find("name")
name := ""
if nameTag != nil {
name = nameTag[1]
if len(name) > 28 {
name = name[:27] + "…"
}
name = clampWithEllipsis(nameTag[1], 28)
}
if name != "" {
name = ": " + color.HiMagentaString(name)
}
desc := spell.Content
if len(desc) > 50 {
desc = desc[0:49] + "…"
}
desc := clampWithEllipsis(spell.Content, 50)
idStr := nip19.EncodeNevent(spell.ID, nil, nostr.ZeroPK)
identifier := "spell" + idStr[len(idStr)-7:]
+321
View File
@@ -0,0 +1,321 @@
package main
import (
"context"
"fmt"
"io"
"os"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip22"
"github.com/fatih/color"
)
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
filterTags := nostr.TagMap{
"E": []string{discussionID.Hex()},
}
for key, values := range extraTags {
filterTags[key] = values
}
comments := make([]nostr.RelayEvent, 0, 15)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{1111},
Tags: filterTags,
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
comments = append(comments, ie)
}
slices.SortFunc(comments, nostr.CompareRelayEvent)
return comments, nil
}
func showThreadWithComments(
ctx context.Context,
relays []string,
evt nostr.RelayEvent,
status string,
extraTags nostr.TagMap,
) error {
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
if err != nil {
return err
}
printThreadMetadata(ctx, os.Stdout, evt, status, true)
stdout("")
stdout(evt.Content)
if len(comments) > 0 {
stdout("")
stdout(color.CyanString("comments:"))
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
}
return nil
}
func printThreadedComments(
ctx context.Context,
w io.Writer,
comments []nostr.RelayEvent,
discussionID nostr.ID,
withColor bool,
) {
byID := make(map[nostr.ID]struct{}, len(comments)+1)
byID[discussionID] = struct{}{}
for _, c := range comments {
byID[c.ID] = struct{}{}
}
// preload metadata from everybody
wg := sync.WaitGroup{}
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
for _, c := range comments {
wg.Go(func() {
sys.FetchProfileMetadata(ctx, c.PubKey)
})
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
if !ok {
continue
}
if _, ok := byID[parent.ID]; ok {
children[parent.ID] = append(children[parent.ID], c)
}
}
for parent := range children {
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
}
wg.Wait()
var render func(parent nostr.ID, depth int)
render = func(parent nostr.ID, depth int) {
for _, c := range children[parent] {
indent := strings.Repeat(" ", depth)
author := authorPreview(ctx, c.PubKey)
created := c.CreatedAt.Time().Format(time.DateTime)
if withColor {
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
} else {
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
}
for _, line := range strings.Split(c.Content, "\n") {
fmt.Fprintln(w, indent+" "+line)
}
fmt.Fprintln(w, indent+"")
render(c.ID, depth+1)
}
}
render(discussionID, 0)
}
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
}
matchCount := 0
matched := nostr.RelayEvent{}
for _, evt := range events {
if strings.HasPrefix(evt.ID.Hex(), prefix) {
matched = evt
matchCount++
}
}
if matchCount == 0 {
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
}
if matchCount > 1 {
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
}
return matched, nil
}
func printThreadMetadata(
ctx context.Context,
w io.Writer,
evt nostr.RelayEvent,
status string,
withColors bool,
) {
label := func(s string) string { return s }
value := func(s string) string { return s }
statusValue := func(s string) string { return s }
if withColors {
label = func(s string) string { return color.CyanString(s) }
value = func(s string) string { return color.HiWhiteString(s) }
statusValue = colorizeGitStatus
}
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
if status != "" {
fmt.Fprintln(w, label("status:"), statusValue(status))
}
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
fmt.Fprintln(w, label("subject:"), value(subject[1]))
fmt.Fprintln(w, "")
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
fmt.Fprintln(w, label("title:"), value(title[1]))
fmt.Fprintln(w, "")
}
}
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
currentParent := discussion
selectedParent := nostr.ZeroID
inComments := false
replyb := strings.Builder{}
for _, line := range strings.Split(edited, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
inComments = false
currentParent = discussion
continue
}
if replyb.Len() == 0 && line == "" {
continue
}
if strings.HasPrefix(line, "#>") {
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
if quoted == "comments:" {
inComments = true
currentParent = discussion
continue
}
// keep track of which comment the reply body shows up below of
// so we can assign it as a reply to that specifically
fields := strings.Fields(quoted)
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
currId := fields[0][1 : len(fields[0])-1]
for _, comment := range comments {
if strings.HasPrefix(comment.ID.Hex(), currId) {
currentParent = comment
break
}
}
}
continue
}
// if we reach here this is a line for the reply input from the user
replyb.WriteString(line)
replyb.WriteByte('\n')
if line == "" {
continue
}
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
}
selectedParent = currentParent.ID
}
content := strings.TrimSpace(replyb.String())
if content == "" {
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
}
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
return content, discussion, nil
}
for _, comment := range comments {
if comment.ID == selectedParent {
return content, comment, nil
}
}
panic("selected reply parent not found (this never happens)")
}
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
lines := make([]string, 0, len(headerLines)+3)
for _, line := range headerLines {
if strings.TrimSpace(line) == "" {
continue
}
lines = append(lines, "# "+strings.TrimSpace(line))
}
lines = append(lines,
"# write your reply here.",
"# lines starting with '#' are ignored.",
"",
)
appender := &lineAppender{lines, "#> "}
printThreadMetadata(ctx, appender, discussion, "", false)
for _, line := range strings.Split(discussion.Content, "\n") {
appender.lines = append(appender.lines, "#> "+line)
}
if len(comments) > 0 {
appender.lines = append(appender.lines, "#> ", "#> comments:")
printThreadedComments(ctx, appender, comments, discussion.ID, false)
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
}
return strings.Join(appender.lines, "\n")
}
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return nostr.ZeroPK, "", "", err
}
meta := sys.FetchProfileMetadata(ctx, pk)
return pk, meta.ShortName(), meta.NpubShort(), nil
}
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
meta := sys.FetchProfileMetadata(ctx, pubkey)
if meta.Name != "" {
return meta.ShortName() + " (" + meta.NpubShort() + ")"
}
return meta.NpubShort()
}
type lineAppender struct {
lines []string
prefix string
}
func (l *lineAppender) Write(b []byte) (int, error) {
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
line = strings.TrimRight(line, " ")
l.lines = append(l.lines, l.prefix+line)
}
return len(b), nil
}
+22 -7
View File
@@ -28,7 +28,7 @@ func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(),
relays := sys.FetchOutboxRelays(ctx, pk, 3)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays, nip60.WalletOptions{})
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
return nil, nil, fmt.Errorf("error loading wallet")
}
w.Processed = func(evt nostr.Event, err error) {
@@ -139,7 +139,11 @@ var wallet = &cli.Command{
}
for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1])
if _, host, ok := strings.Cut(url, "://"); ok {
stdout(host)
} else {
stdout(url)
}
}
closew()
@@ -195,7 +199,11 @@ var wallet = &cli.Command{
}
for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
stdout(token.ID(), token.Proofs.Amount(), mintHost)
}
closew()
@@ -221,7 +229,11 @@ var wallet = &cli.Command{
for _, token := range w.Tokens {
if slices.Contains(ids, token.ID()) {
w.DropToken(ctx, token.ID())
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
_, mintHost, _ := strings.Cut(token.Mint, "://")
if mintHost == "" {
mintHost = token.Mint
}
log("dropped %s %d %s\n", token.ID(), token.Proofs.Amount(), mintHost)
}
}
@@ -358,6 +370,10 @@ var wallet = &cli.Command{
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "target",
Usage: "npub, nprofile, nevent or hex pubkey",
},
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
@@ -368,9 +384,8 @@ var wallet = &cli.Command{
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) < 2 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
if c.Args().Len() < 1 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> --target <target>...")
}
w, closew, err := prepareWallet(ctx, c)