mirror of
https://github.com/fiatjaf/nak.git
synced 2026-02-13 03:24:31 +00:00
Compare commits
4 Commits
v0.18.0
...
e05b455a05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e05b455a05 | ||
|
|
9190c9d988 | ||
|
|
e64ad8f078 | ||
|
|
b36718caaa |
@@ -427,6 +427,7 @@ gitnostr.com... ok.
|
|||||||
```shell
|
```shell
|
||||||
~> nak git clone
|
~> nak git clone
|
||||||
~> nak git init
|
~> nak git init
|
||||||
|
~> nak git status
|
||||||
~> nak git sync
|
~> nak git sync
|
||||||
~> nak git fetch
|
~> nak git fetch
|
||||||
~> nak git pull
|
~> nak git pull
|
||||||
|
|||||||
162
git.go
162
git.go
@@ -181,7 +181,7 @@ aside from those, there is also:
|
|||||||
var fetchedRepo *nip34.Repository
|
var fetchedRepo *nip34.Repository
|
||||||
if existingConfig.Identifier == "" {
|
if existingConfig.Identifier == "" {
|
||||||
log(" searching for existing events... ")
|
log(" searching for existing events... ")
|
||||||
repo, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
repo, _, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
||||||
if err == nil && repo.Event.ID != nostr.ZeroID {
|
if err == nil && repo.Event.ID != nostr.ZeroID {
|
||||||
fetchedRepo = &repo
|
fetchedRepo = &repo
|
||||||
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
||||||
@@ -371,7 +371,7 @@ aside from those, there is also:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository metadata and state
|
// fetch repository metadata and state
|
||||||
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -782,6 +782,98 @@ aside from those, there is also:
|
|||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "status",
|
||||||
|
Usage: "show repository status and synchronization information",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
// read local config
|
||||||
|
localConfig, err := readNip34ConfigFile("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse owner
|
||||||
|
owner, err := parsePubKey(localConfig.Owner)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid owner public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := localConfig.ToRepository()
|
||||||
|
stdout("\n" + color.CyanString("metadata:"))
|
||||||
|
stdout(" identifier:", color.CyanString(repo.ID))
|
||||||
|
stdout(" name:", color.CyanString(repo.Name))
|
||||||
|
stdout(" owner:", color.CyanString(nip19.EncodeNpub(repo.Event.PubKey)))
|
||||||
|
stdout(" description:", color.CyanString(repo.Description))
|
||||||
|
stdout(" web urls:")
|
||||||
|
for _, url := range repo.Web {
|
||||||
|
stdout(" ", url)
|
||||||
|
}
|
||||||
|
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
|
||||||
|
|
||||||
|
// fetch repository announcement and state from relays
|
||||||
|
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||||
|
if err != nil {
|
||||||
|
// create a local repo object for display purposes
|
||||||
|
log("failed to fetch repository announcement from relays: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil {
|
||||||
|
stdout(color.YellowString("\n repository state not published."))
|
||||||
|
}
|
||||||
|
|
||||||
|
stateHEAD, _ := state.Branches[state.HEAD]
|
||||||
|
|
||||||
|
stdout("\n" + color.CyanString("grasp status:"))
|
||||||
|
rows := make([][3]string, len(localConfig.GraspServers))
|
||||||
|
for s, server := range localConfig.GraspServers {
|
||||||
|
row := [3]string{}
|
||||||
|
|
||||||
|
url := graspServerHost(server)
|
||||||
|
row[0] = url
|
||||||
|
|
||||||
|
upToDate := upToDateRelays != nil && slices.ContainsFunc(upToDateRelays, func(s string) bool { return graspServerHost(s) == url })
|
||||||
|
if upToDate {
|
||||||
|
row[1] = color.GreenString("announcement up-to-date")
|
||||||
|
} else {
|
||||||
|
row[1] = color.YellowString("announcement outdated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != nil {
|
||||||
|
remoteName := gitRemoteName(url)
|
||||||
|
refSpec := fmt.Sprintf("refs/remotes/%s/HEAD", remoteName)
|
||||||
|
lsRemoteCmd := exec.Command("git", "rev-parse", "--verify", refSpec)
|
||||||
|
commitOutput, err := lsRemoteCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
row[2] = color.YellowString("repository not pushed")
|
||||||
|
} else {
|
||||||
|
commit := strings.TrimSpace(string(commitOutput))
|
||||||
|
if commit == stateHEAD {
|
||||||
|
row[2] = color.GreenString("repository synced with state")
|
||||||
|
} else {
|
||||||
|
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", state.HEAD, commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows[s] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCol := [3]int{}
|
||||||
|
for i := range maxCol {
|
||||||
|
for _, row := range rows {
|
||||||
|
if len(row[i]) > maxCol[i] {
|
||||||
|
maxCol[i] = len(row[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
line := " " + row[0] + strings.Repeat(" ", maxCol[0]-len(row[0])) + " " + strings.Repeat(" ", maxCol[1]-len(row[1])) + row[1] + " " + strings.Repeat(" ", maxCol[2]-len(row[2])) + row[2]
|
||||||
|
stdout(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +961,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository announcement and state from relays
|
// fetch repository announcement and state from relays
|
||||||
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||||
notUpToDate := func(graspServer string) bool {
|
notUpToDate := func(graspServer string) bool {
|
||||||
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
||||||
}
|
}
|
||||||
@@ -889,33 +981,40 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
}
|
}
|
||||||
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
||||||
}
|
}
|
||||||
// create a local repository object from config and publish it
|
var event nostr.Event
|
||||||
localRepo := localConfig.ToRepository()
|
if upToDateAnnouncementEvent != nil {
|
||||||
|
// publish the latest event to the other relays
|
||||||
if signer != nil {
|
event = *upToDateAnnouncementEvent
|
||||||
signerPk, err := signer.GetPublicKey(ctx)
|
repo = nip34.ParseRepository(event)
|
||||||
if err != nil {
|
} else {
|
||||||
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
|
// create a local repository object from config and publish it
|
||||||
}
|
localRepo := localConfig.ToRepository()
|
||||||
if signerPk != owner {
|
if signer != nil {
|
||||||
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
signerPk, err := signer.GetPublicKey(ctx)
|
||||||
} else {
|
if err != nil {
|
||||||
event := localRepo.ToEvent()
|
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
|
||||||
if err := signer.SignEvent(ctx, &event); err != nil {
|
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if signerPk != owner {
|
||||||
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
||||||
if res.Error != nil {
|
} else {
|
||||||
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
event = localRepo.ToEvent()
|
||||||
} else {
|
if err := signer.SignEvent(ctx, &event); err != nil {
|
||||||
log("> published to %s\n", color.GreenString(res.RelayURL))
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repo = localRepo
|
} else {
|
||||||
|
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
||||||
|
}
|
||||||
|
|
||||||
|
repo = localRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
|
||||||
|
if res.Error != nil {
|
||||||
|
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
||||||
|
} else {
|
||||||
|
log("> published to %s\n", color.GreenString(res.RelayURL))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -951,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
} else {
|
} else {
|
||||||
log("local configuration is newer, publishing updated repository announcement...\n")
|
log("local configuration is newer, publishing updated repository announcement...\n")
|
||||||
announcementEvent := localRepo.ToEvent()
|
announcementEvent := localRepo.ToEvent()
|
||||||
|
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
|
||||||
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1155,7 +1255,7 @@ func fetchRepositoryAndState(
|
|||||||
pubkey nostr.PubKey,
|
pubkey nostr.PubKey,
|
||||||
identifier string,
|
identifier string,
|
||||||
relayHints []string,
|
relayHints []string,
|
||||||
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||||
// fetch repository announcement (30617)
|
// fetch repository announcement (30617)
|
||||||
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
@@ -1176,13 +1276,15 @@ func fetchRepositoryAndState(
|
|||||||
|
|
||||||
// reset this list as the previous was for relays with the older version
|
// reset this list as the previous was for relays with the older version
|
||||||
upToDateRelays = []string{ie.Relay.URL}
|
upToDateRelays = []string{ie.Relay.URL}
|
||||||
|
|
||||||
|
upToDateAnnouncementEvent = &ie.Event
|
||||||
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
||||||
// we discard this because it's the same, but this relay is up-to-date
|
// we discard this because it's the same, but this relay is up-to-date
|
||||||
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if repo.Event.ID == nostr.ZeroID {
|
if repo.Event.ID == nostr.ZeroID {
|
||||||
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository state (30618)
|
// fetch repository state (30618)
|
||||||
@@ -1212,10 +1314,10 @@ func fetchRepositoryAndState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stateErr != nil {
|
if stateErr != nil {
|
||||||
return repo, upToDateRelays, state, stateErr
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo, upToDateRelays, state, nil
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateErr struct{ string }
|
type StateErr struct{ string }
|
||||||
|
|||||||
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-20260119010708-31af06f4c7c4
|
||||||
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-20260119010708-31af06f4c7c4 h1:/6AVjHIbbgyuiilcUuoFPMXGNXqialKGQM7uskF0b/0=
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4/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=
|
||||||
|
|||||||
383
group.go
Normal file
383
group.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
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: "<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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user