mirror of
https://github.com/fiatjaf/nak.git
synced 2026-08-05 22:04:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae98e99ec6 | ||
|
|
29db04b7ea | ||
|
|
59fecacbdc | ||
|
|
faac4d9440 | ||
|
|
f00a4a7d2a | ||
|
|
d823ac55a6 | ||
|
|
105ccef7ec | ||
|
|
9d4df21836 | ||
|
|
952d638ac3 | ||
|
|
c1e72e0af1 | ||
|
|
5f4efdbc69 | ||
|
|
4967db13a1 | ||
|
|
da0b753371 | ||
|
|
bef67d35d2 | ||
|
|
a8fb2e4189 | ||
|
|
7aea4cf9a1 | ||
|
|
d472efe707 | ||
|
|
17341b3af6 | ||
|
|
ff5a7b4ba7 | ||
|
|
7596e317b8 | ||
|
|
da7fc3fac3 | ||
|
|
0e8475b388 | ||
|
|
5532c884bc | ||
|
|
05857ab190 | ||
|
|
bf22a63404 | ||
|
|
6bd2d1cdfc | ||
|
|
9c8d59c5c5 | ||
|
|
5957c08d15 | ||
|
|
f59c8a670d | ||
|
|
61a3b89d08 | ||
|
|
d61fdc4cb4 | ||
|
|
0735ded0fc | ||
|
|
5233a77510 | ||
|
|
fd34cc7c5e | ||
|
|
2d151c2ac8 | ||
|
|
037e8efcc6 | ||
|
|
1b380dea9a | ||
|
|
f126b3f7ee | ||
|
|
dc5ffe5129 | ||
|
|
7637b5018f | ||
|
|
d5ab34bb2f | ||
|
|
49345333c4 | ||
|
|
b5de7b78bc | ||
|
|
ba9a5badc6 |
+3
-3
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user