mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-25 20:08:52 +00:00
Compare commits
9 Commits
e64ad8f078
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee6320312 | ||
|
|
91474d65eb | ||
|
|
7d782737c4 | ||
|
|
9160c68cb5 | ||
|
|
bf19f38996 | ||
|
|
4e2c136e45 | ||
|
|
8cef1ed0ea | ||
|
|
e05b455a05 | ||
|
|
9190c9d988 |
299
bunker.go
299
bunker.go
@@ -4,13 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -73,13 +74,7 @@ var bunker = &cli.Command{
|
|||||||
},
|
},
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// read config from file
|
// read config from file
|
||||||
config := struct {
|
config := BunkerConfig{}
|
||||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
|
|
||||||
Secret plainOrEncryptedKey `json:"sec"`
|
|
||||||
Relays []string `json:"relays"`
|
|
||||||
}{
|
|
||||||
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
|
|
||||||
}
|
|
||||||
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||||
for i, url := range baseRelaysUrls {
|
for i, url := range baseRelaysUrls {
|
||||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||||
@@ -142,6 +137,15 @@ var bunker = &cli.Command{
|
|||||||
if err := json.Unmarshal(b, &config); err != nil {
|
if err := json.Unmarshal(b, &config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// convert from deprecated field
|
||||||
|
if len(config.AuthorizedKeys) > 0 {
|
||||||
|
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
|
||||||
|
for i := range config.AuthorizedKeys {
|
||||||
|
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
|
||||||
|
}
|
||||||
|
config.AuthorizedKeys = nil
|
||||||
|
persist()
|
||||||
|
}
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,11 @@ var bunker = &cli.Command{
|
|||||||
config.Relays[i] = nostr.NormalizeURL(url)
|
config.Relays[i] = nostr.NormalizeURL(url)
|
||||||
}
|
}
|
||||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
|
for _, bak := range baseAuthorizedKeys {
|
||||||
|
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
|
||||||
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||||
// we don't have any secret key stored, so just use whatever was given via flags
|
// we don't have any secret key stored, so just use whatever was given via flags
|
||||||
@@ -167,7 +175,9 @@ var bunker = &cli.Command{
|
|||||||
} else {
|
} else {
|
||||||
config.Secret = baseSecret
|
config.Secret = baseSecret
|
||||||
config.Relays = baseRelaysUrls
|
config.Relays = baseRelaysUrls
|
||||||
config.AuthorizedKeys = baseAuthorizedKeys
|
for _, bak := range baseAuthorizedKeys {
|
||||||
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||||
@@ -205,8 +215,17 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
qs := url.Values{}
|
qs := url.Values{}
|
||||||
relayURLs := make([]string, 0, len(config.Relays))
|
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||||
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
|
copy(allRelays, config.Relays)
|
||||||
|
for _, c := range config.Clients {
|
||||||
|
for _, url := range c.CustomRelays {
|
||||||
|
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
|
||||||
|
allRelays = append(allRelays, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relayURLs := make([]string, 0, len(allRelays))
|
||||||
|
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
@@ -236,10 +255,22 @@ var bunker = &cli.Command{
|
|||||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||||
|
|
||||||
authorizedKeysStr := ""
|
authorizedKeysStr := ""
|
||||||
if len(config.AuthorizedKeys) != 0 {
|
if len(config.Clients) != 0 {
|
||||||
authorizedKeysStr = "\n authorized keys:"
|
authorizedKeysStr = "\n authorized clients:"
|
||||||
for _, pubkey := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
|
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
|
||||||
|
name := ""
|
||||||
|
if c.Name != "" {
|
||||||
|
name = c.Name
|
||||||
|
if c.URL != "" {
|
||||||
|
name += " " + colors.underline(c.URL)
|
||||||
|
}
|
||||||
|
} else if c.URL != "" {
|
||||||
|
name = colors.underline(c.URL)
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
authorizedKeysStr += " (" + name + ")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +280,8 @@ var bunker = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
preauthorizedFlags := ""
|
preauthorizedFlags := ""
|
||||||
for _, k := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
preauthorizedFlags += " -k " + k.Hex()
|
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||||
}
|
}
|
||||||
for _, s := range authorizedSecrets {
|
for _, s := range authorizedSecrets {
|
||||||
preauthorizedFlags += " -s " + s
|
preauthorizedFlags += " -s " + s
|
||||||
@@ -314,28 +345,85 @@ var bunker = &cli.Command{
|
|||||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||||
Since: nostr.Now(),
|
Since: nostr.Now(),
|
||||||
LimitZero: true,
|
LimitZero: true,
|
||||||
}, nostr.SubscriptionOptions{
|
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||||
Label: "nak-bunker",
|
|
||||||
})
|
|
||||||
|
|
||||||
signer := nip46.NewStaticKeySigner(sec)
|
signer := nip46.NewStaticKeySigner(sec)
|
||||||
handlerWg := sync.WaitGroup{}
|
signer.DefaultRelays = config.Relays
|
||||||
printLock := sync.Mutex{}
|
|
||||||
|
// unix socket nostrconnect:// handling
|
||||||
|
go func() {
|
||||||
|
for uri := range onSocketConnect(ctx, c) {
|
||||||
|
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
|
||||||
|
|
||||||
|
relays := uri.Query()["relay"]
|
||||||
|
|
||||||
|
// pre-authorize this client since the user has explicitly added it
|
||||||
|
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
||||||
|
return c.PubKey == clientPublicKey
|
||||||
|
}) {
|
||||||
|
config.Clients = append(config.Clients, BunkerConfigClient{
|
||||||
|
PubKey: clientPublicKey,
|
||||||
|
Name: uri.Query().Get("name"),
|
||||||
|
URL: uri.Query().Get("url"),
|
||||||
|
Icon: uri.Query().Get("icon"),
|
||||||
|
CustomRelays: relays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if persist != nil {
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
||||||
|
if err != nil {
|
||||||
|
log("* failed to handle: %s\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||||
|
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||||
|
Since: nostr.Now(),
|
||||||
|
LimitZero: true,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
|
||||||
|
events <- event
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond * 25)
|
||||||
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
|
log("~ responding with %s\n", string(jresp))
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
|
if res.Error == nil {
|
||||||
|
log("* sent through %s\n", res.Relay.URL)
|
||||||
|
} else {
|
||||||
|
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// just a gimmick
|
// just a gimmick
|
||||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||||
_, cancel := context.WithCancel(ctx)
|
_, cancel := context.WithCancel(ctx)
|
||||||
cancelPreviousBunkerInfoPrint = cancel
|
cancelPreviousBunkerInfoPrint = cancel
|
||||||
|
|
||||||
// asking user for authorization
|
|
||||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||||
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
|
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if slices.Contains(authorizedSecrets, secret) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if secret == newSecret {
|
if secret == newSecret {
|
||||||
// store this key
|
// store this key
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||||
// discard this and generate a new secret
|
// discard this and generate a new secret
|
||||||
newSecret = randString(12)
|
newSecret = randString(12)
|
||||||
// print bunker info again after this
|
// print bunker info again after this
|
||||||
@@ -358,34 +446,39 @@ var bunker = &cli.Command{
|
|||||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||||
|
|
||||||
// handle the NIP-46 request event
|
// handle the NIP-46 request event
|
||||||
|
from := ie.Event.PubKey
|
||||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
if errors.Is(err, nip46.AlreadyHandled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
|
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
log("~ responding with %s\n", string(jresp))
|
log("~ responding with %s\n", string(jresp))
|
||||||
|
|
||||||
handlerWg.Add(len(relayURLs))
|
// use custom relays if they are defined for this client
|
||||||
for _, relayURL := range relayURLs {
|
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||||
go func(relayURL string) {
|
relays := relayURLs
|
||||||
defer handlerWg.Done()
|
for _, c := range config.Clients {
|
||||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||||
err := relay.Publish(ctx, eventResponse)
|
relays = c.CustomRelays
|
||||||
printLock.Lock()
|
break
|
||||||
if err == nil {
|
}
|
||||||
log("* sent response through %s\n", relay.URL)
|
}
|
||||||
} else {
|
|
||||||
log("* failed to send response: %s\n", err)
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
}
|
if res.Error == nil {
|
||||||
printLock.Unlock()
|
log("* sent response through %s\n", res.Relay.URL)
|
||||||
}
|
} else {
|
||||||
}(relayURL)
|
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handlerWg.Wait()
|
|
||||||
|
|
||||||
// just after handling one request we trigger this
|
// just after handling one request we trigger this
|
||||||
go func() {
|
go func() {
|
||||||
@@ -410,24 +503,44 @@ var bunker = &cli.Command{
|
|||||||
Name: "connect",
|
Name: "connect",
|
||||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||||
ArgsUsage: "<nostrconnect-uri>",
|
ArgsUsage: "<nostrconnect-uri>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "profile",
|
||||||
|
Usage: "profile name of the bunker to connect to",
|
||||||
|
},
|
||||||
|
},
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
if c.Args().Len() != 1 {
|
if c.Args().Len() != 1 {
|
||||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
uri, err := url.Parse(c.Args().First())
|
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||||
if err != nil || uri.Scheme != "nostrconnect" {
|
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||||
return fmt.Errorf("invalid uri")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
return nil
|
||||||
|
|
||||||
return fmt.Errorf("this is not implemented yet")
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BunkerConfig struct {
|
||||||
|
Clients []BunkerConfigClient `json:"clients"`
|
||||||
|
Secret plainOrEncryptedKey `json:"sec"`
|
||||||
|
Relays []string `json:"relays"`
|
||||||
|
|
||||||
|
// deprecated
|
||||||
|
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BunkerConfigClient struct {
|
||||||
|
PubKey nostr.PubKey `json:"pubkey"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
CustomRelays []string `json:"custom_relays,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type plainOrEncryptedKey struct {
|
type plainOrEncryptedKey struct {
|
||||||
Plain *nostr.SecretKey
|
Plain *nostr.SecretKey
|
||||||
Encrypted *string
|
Encrypted *string
|
||||||
@@ -495,3 +608,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSocketPath(c *cli.Command) string {
|
||||||
|
profile := "default"
|
||||||
|
if c.IsSet("profile") {
|
||||||
|
profile = c.String("profile")
|
||||||
|
}
|
||||||
|
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
||||||
|
res := make(chan *url.URL)
|
||||||
|
socketPath := getSocketPath(c)
|
||||||
|
|
||||||
|
// ensure directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||||
|
log(color.RedString("failed to create socket directory: %w\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))
|
||||||
|
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))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer listener.Close()
|
||||||
|
defer os.Remove(socketPath) // cleanup socket file on exit
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, err := url.Parse(string(buf[:n]))
|
||||||
|
if err == nil && uri.Scheme == "nostrconnect" {
|
||||||
|
res <- uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendToSocket(c *cli.Command, value string) error {
|
||||||
|
socketPath := getSocketPath(c)
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte(value))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send uri to bunker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
2
git.go
2
git.go
@@ -850,7 +850,7 @@ aside from those, there is also:
|
|||||||
if commit == stateHEAD {
|
if commit == stateHEAD {
|
||||||
row[2] = color.GreenString("repository synced with state")
|
row[2] = color.GreenString("repository synced with state")
|
||||||
} else {
|
} else {
|
||||||
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", state.HEAD, commit)
|
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
github.com/bep/debounce v1.2.1
|
github.com/bep/debounce v1.2.1
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,9 +1,7 @@
|
|||||||
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||||
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4 h1:1KAEp9ktrnm7pB/o2QrdRT3dJyXYwei8N9RRRppiMFY=
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||||
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7 h1:CkMr8zFLfoOO59+oNlBXXrga00lTKyl2A4fUXAJQ7fY=
|
|
||||||
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
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/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||||
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
||||||
|
|||||||
586
group.go
Normal file
586
group.go
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip29"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var group = &cli.Command{
|
||||||
|
Name: "group",
|
||||||
|
Aliases: []string{"nip29"},
|
||||||
|
Usage: "group-related operations: info, chat, forum, members, admins, roles",
|
||||||
|
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "info",
|
||||||
|
Usage: "show group information",
|
||||||
|
Description: "displays basic group metadata.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
|
||||||
|
stdout("name:", color.HiBlueString(group.Name))
|
||||||
|
stdout("picture:", color.HiBlueString(group.Picture))
|
||||||
|
stdout("about:", color.HiBlueString(group.About))
|
||||||
|
stdout("restricted:",
|
||||||
|
color.HiBlueString("%s", cond(group.Restricted, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Restricted, "only explicit members can publish", "non-members can publish (restricted by relay policy)"),
|
||||||
|
)
|
||||||
|
stdout("closed:",
|
||||||
|
color.HiBlueString("%s", cond(group.Closed, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Closed, "joining requires an invite", "anyone can join (restricted by relay policy)"),
|
||||||
|
)
|
||||||
|
stdout("hidden:",
|
||||||
|
color.HiBlueString("%s", cond(group.Hidden, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Hidden, "group doesn't show up when listing relay groups", "group is visible to users browsing the relay"),
|
||||||
|
)
|
||||||
|
stdout("private:",
|
||||||
|
color.HiBlueString("%s", cond(group.Private, "yes", "no"))+
|
||||||
|
", "+
|
||||||
|
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "members",
|
||||||
|
Usage: "list and manage group members",
|
||||||
|
Description: "view group membership information.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupMembers},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInMembersEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string)
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
for member, roles := range group.Members {
|
||||||
|
wg.Go(func() {
|
||||||
|
line := member.Hex()
|
||||||
|
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, member)
|
||||||
|
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||||
|
|
||||||
|
for _, role := range roles {
|
||||||
|
line += ", " + role.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
lines <- line
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for line := range lines {
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "admins",
|
||||||
|
Usage: "manage group administrators",
|
||||||
|
Description: "view and manage group admin permissions.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupAdmins},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInAdminsEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string)
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
for member, roles := range group.Members {
|
||||||
|
wg.Go(func() {
|
||||||
|
line := member.Hex()
|
||||||
|
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, member)
|
||||||
|
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||||
|
|
||||||
|
for _, role := range roles {
|
||||||
|
line += ", " + role.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
lines <- line
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for line := range lines {
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "roles",
|
||||||
|
Usage: "manage group roles and permissions",
|
||||||
|
Description: "configure custom roles and permissions within the group.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
group := nip29.Group{
|
||||||
|
Roles: make([]*nip29.Role, 0),
|
||||||
|
}
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupRoles},
|
||||||
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
if err := group.MergeInRolesEvent(&ie.Event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, role := range group.Roles {
|
||||||
|
stdout(color.HiBlueString(role.Name) + " " + role.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chat",
|
||||||
|
Usage: "send and read group chat messages",
|
||||||
|
Description: "interact with group chat functionality.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := r.Subscribe(ctx, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9},
|
||||||
|
Tags: nostr.TagMap{"h": []string{identifier}},
|
||||||
|
Limit: 200,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
eosed := false
|
||||||
|
messages := make([]struct {
|
||||||
|
message string
|
||||||
|
rendered bool
|
||||||
|
}, 200)
|
||||||
|
base := len(messages)
|
||||||
|
|
||||||
|
tryRender := func(i int) {
|
||||||
|
// if all messages before these are loaded we can render this,
|
||||||
|
// otherwise we render whatever we can and stop
|
||||||
|
for m, msg := range messages[base:] {
|
||||||
|
if msg.rendered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.message == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messages[base+m].rendered = true
|
||||||
|
stdout(msg.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt := <-sub.Events:
|
||||||
|
var i int
|
||||||
|
if eosed {
|
||||||
|
i = len(messages)
|
||||||
|
messages = append(messages, struct {
|
||||||
|
message string
|
||||||
|
rendered bool
|
||||||
|
}{})
|
||||||
|
} else {
|
||||||
|
base--
|
||||||
|
i = base
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||||
|
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
|
||||||
|
|
||||||
|
if eosed {
|
||||||
|
tryRender(i)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case reason := <-sub.ClosedReason:
|
||||||
|
stdout("closed:" + color.YellowString(reason))
|
||||||
|
case <-sub.EndOfStoredEvents:
|
||||||
|
eosed = true
|
||||||
|
tryRender(len(messages) - 1)
|
||||||
|
case <-sub.Context.Done():
|
||||||
|
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "send",
|
||||||
|
Usage: "sends a message to the chat",
|
||||||
|
ArgsUsage: "<relay>'<identifier> <message>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := nostr.Event{
|
||||||
|
Kind: 9,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: strings.Join(c.Args().Tail(), " "),
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"h", identifier},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &msg); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
return r.Publish(ctx, msg)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "forum",
|
||||||
|
Usage: "read group forum posts",
|
||||||
|
Description: "access group forum functionality.",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// TODO: see what to do about this
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "put-user",
|
||||||
|
Usage: "add a user to the group with optional roles",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&PubKeyFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "role",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
|
||||||
|
pubkey := getPubKey(c, "pubkey")
|
||||||
|
tag := nostr.Tag{"p", pubkey.Hex()}
|
||||||
|
tag = append(tag, c.StringSlice("role")...)
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove-user",
|
||||||
|
Usage: "remove a user from the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier> <pubkey>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&PubKeyFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
|
||||||
|
pubkey := getPubKey(c, "pubkey")
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "edit-metadata",
|
||||||
|
Usage: "edits the group metadata",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "name",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "about",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "picture",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "restricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "unrestricted",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "closed",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "open",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "hidden",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "visible",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "private",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "public",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
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{"restricted"})
|
||||||
|
} else if c.Bool("unrestricted") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
||||||
|
}
|
||||||
|
if c.Bool("closed") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
||||||
|
} else if c.Bool("open") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
||||||
|
}
|
||||||
|
if c.Bool("hidden") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
||||||
|
} else if c.Bool("visible") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
|
||||||
|
}
|
||||||
|
if c.Bool("private") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
||||||
|
} else if c.Bool("public") {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete-event",
|
||||||
|
Usage: "delete an event from the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&IDFlag{
|
||||||
|
Name: "event",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
|
||||||
|
id := getID(c, "event")
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete-group",
|
||||||
|
Usage: "deletes the group",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create-invite",
|
||||||
|
Usage: "creates an invite code",
|
||||||
|
ArgsUsage: "<relay>'<identifier>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "code",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
|
||||||
|
args := c.Args().Slice()
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("requires group identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: kind,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: "",
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"h", identifier},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setupFunc(&evt, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout(evt.String())
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Publish(ctx, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cond(b bool, ifYes string, ifNo string) string {
|
||||||
|
if b {
|
||||||
|
return ifYes
|
||||||
|
}
|
||||||
|
return ifNo
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err error) {
|
||||||
|
groupArg := c.Args().First()
|
||||||
|
if !strings.Contains(groupArg, "'") {
|
||||||
|
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(groupArg, "'", 2)
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
|
||||||
|
}
|
||||||
22
helpers.go
22
helpers.go
@@ -536,21 +536,25 @@ func decodeTagValue(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var colors = struct {
|
var colors = struct {
|
||||||
reset func(...any) (int, error)
|
reset func(...any) (int, error)
|
||||||
italic func(...any) string
|
italic func(...any) string
|
||||||
italicf func(string, ...any) string
|
italicf func(string, ...any) string
|
||||||
bold func(...any) string
|
bold func(...any) string
|
||||||
boldf func(string, ...any) string
|
boldf func(string, ...any) string
|
||||||
error func(...any) string
|
underline func(...any) string
|
||||||
errorf func(string, ...any) string
|
underlinef func(string, ...any) string
|
||||||
success func(...any) string
|
error func(...any) string
|
||||||
successf func(string, ...any) string
|
errorf func(string, ...any) string
|
||||||
|
success func(...any) string
|
||||||
|
successf func(string, ...any) string
|
||||||
}{
|
}{
|
||||||
color.New(color.Reset).Print,
|
color.New(color.Reset).Print,
|
||||||
color.New(color.Italic).Sprint,
|
color.New(color.Italic).Sprint,
|
||||||
color.New(color.Italic).Sprintf,
|
color.New(color.Italic).Sprintf,
|
||||||
color.New(color.Bold).Sprint,
|
color.New(color.Bold).Sprint,
|
||||||
color.New(color.Bold).Sprintf,
|
color.New(color.Bold).Sprintf,
|
||||||
|
color.New(color.Underline).Sprint,
|
||||||
|
color.New(color.Underline).Sprintf,
|
||||||
color.New(color.Bold, color.FgHiRed).Sprint,
|
color.New(color.Bold, color.FgHiRed).Sprint,
|
||||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||||
|
|||||||
Reference in New Issue
Block a user