diff --git a/go.mod b/go.mod index fa4c31a..6785167 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fiatjaf/nak go 1.25 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/bep/debounce v1.2.1 github.com/btcsuite/btcd/btcec/v2 v2.3.6 diff --git a/go.sum b/go.sum index f699988..8aac35e 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q= fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc= -fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/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= +fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4 h1:/6AVjHIbbgyuiilcUuoFPMXGNXqialKGQM7uskF0b/0= +fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= diff --git a/group.go b/group.go new file mode 100644 index 0000000..a6361e2 --- /dev/null +++ b/group.go @@ -0,0 +1,357 @@ +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 '" where host.tld is the relay and identifier is the group identifier.`, + DisableSliceFlagSeparator: true, + ArgsUsage: " ' [flags]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "sec", + Usage: "secret key to sign events, as nsec, ncryptsec or hex, or a bunker URL", + Category: CATEGORY_SIGNER, + }, + }, + DefaultCommand: "info", + Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { + if c.Args().Len() < 1 { + return ctx, fmt.Errorf("missing group identifier, try `nak group info '`") + } + + return parseGroupIdentifier(ctx, c) + }, + Commands: []*cli.Command{ + { + Name: "info", + Usage: "show group information", + Description: "displays basic group metadata.", + ArgsUsage: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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)) + } + } + }, + }, + { + Name: "forum", + Usage: "read group forum posts", + Description: "access group forum functionality.", + ArgsUsage: "'", + Action: func(ctx context.Context, c *cli.Command) error { + relay := ctx.Value("relay").(string) + identifier := ctx.Value("identifier").(string) + + 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("")) + } + 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(ctx context.Context, c *cli.Command) (context.Context, error) { + args := c.Args().Slice() + if len(args) < 1 { + return ctx, fmt.Errorf("missing group identifier in format '") + } + + groupArg := args[len(args)-1] + if !strings.Contains(groupArg, "'") { + return ctx, fmt.Errorf("invalid group identifier format, expected '") + } + + parts := strings.SplitN(groupArg, "'", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return ctx, fmt.Errorf("invalid group identifier format, expected '") + } + + ctx = context.WithValue(ctx, "relay", parts[0]) + ctx = context.WithValue(ctx, "identifier", parts[1]) + + return ctx, nil +} diff --git a/main.go b/main.go index c990217..cf5e7c9 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ var app = &cli.Command{ fsCmd, publish, git, + group, nip, syncCmd, spell,