Compare commits

...

9 Commits

Author SHA1 Message Date
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
7 changed files with 853 additions and 65 deletions

299
bunker.go
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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
}

View File

@@ -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,

View File

@@ -50,6 +50,7 @@ var app = &cli.Command{
fsCmd, fsCmd,
publish, publish,
git, git,
group,
nip, nip,
syncCmd, syncCmd,
spell, spell,