mirror of
https://github.com/fiatjaf/nak.git
synced 2026-04-10 15:36:54 +02:00
1104 lines
29 KiB
Go
1104 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
stdjson "encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"fiatjaf.com/nostr/nip11"
|
|
"fiatjaf.com/nostr/nip29"
|
|
"github.com/fatih/color"
|
|
"github.com/urfave/cli/v3"
|
|
)
|
|
|
|
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, err := fetchGroupMetadata(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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"),
|
|
)
|
|
stdout("livekit:",
|
|
color.HiBlueString("%s", cond(group.LiveKit, "yes", "no"))+
|
|
", "+
|
|
cond(group.LiveKit, "group supports live audio/video with livekit", "group has no advertised live audio/video support"),
|
|
)
|
|
supportedKinds := "unspecified"
|
|
if group.SupportedKinds != nil {
|
|
if len(group.SupportedKinds) == 0 {
|
|
supportedKinds = "none"
|
|
} else {
|
|
kinds := make([]string, 0, len(group.SupportedKinds))
|
|
for _, kind := range group.SupportedKinds {
|
|
kinds = append(kinds, strconv.Itoa(int(kind)))
|
|
}
|
|
supportedKinds = strings.Join(kinds, ", ")
|
|
}
|
|
}
|
|
stdout("supported-kinds:", color.HiBlueString(supportedKinds))
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
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
|
|
}
|
|
|
|
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: "talk",
|
|
Usage: "get livekit connection details",
|
|
Description: "requests a livekit jwt for this group and prints the livekit server url.",
|
|
ArgsUsage: "<relay>'<identifier>",
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
relay, identifier, err := parseGroupIdentifier(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
group, err := fetchGroupMetadata(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !group.LiveKit {
|
|
return fmt.Errorf("group doesn't advertise livekit support")
|
|
}
|
|
|
|
serverURL, jwt, err := requestLivekitJWT(ctx, c, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stdout("livekit:", color.HiBlueString(serverURL))
|
|
stdout("jwt:", color.HiBlueString(jwt))
|
|
stdout("join:", color.HiBlueString(
|
|
fmt.Sprintf("https://meet.livekit.io/custom?liveKitUrl=%s&token=%s", serverURL, jwt)),
|
|
)
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
Name: "forum",
|
|
Usage: "forum topic operations",
|
|
Description: "when called directly, lists forum topics; with an id prefix, displays that topic with threaded comments.",
|
|
ArgsUsage: "<relay>'<identifier> [id-prefix]",
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
relay, identifier, err := parseGroupIdentifier(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
prefix := strings.TrimSpace(c.Args().Get(1))
|
|
if prefix == "" {
|
|
if len(topics) == 0 {
|
|
log("no forum topics found\n")
|
|
return nil
|
|
}
|
|
|
|
wg := sync.WaitGroup{}
|
|
for _, evt := range topics {
|
|
wg.Go(func() {
|
|
sys.FetchProfileMetadata(ctx, evt.PubKey)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
for _, evt := range topics {
|
|
id := evt.ID.Hex()
|
|
date := evt.CreatedAt.Time().Format(time.DateOnly)
|
|
author := authorPreview(ctx, evt.PubKey)
|
|
subject := forumSubjectPreview(evt, 72)
|
|
if subject == "" {
|
|
subject = "<untitled>"
|
|
}
|
|
stdout(color.CyanString(id[:6]), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
evt, err := findEventByPrefix(topics, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return showThreadWithComments(ctx, []string{relay}, evt, "", nostr.TagMap{"h": []string{identifier}})
|
|
},
|
|
Commands: []*cli.Command{
|
|
{
|
|
Name: "create",
|
|
Usage: "edit and send a forum topic event (kind 11)",
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
relay, identifier, err := parseGroupIdentifier(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groupName := groupMeta.Name
|
|
if groupName == "" {
|
|
groupName = identifier
|
|
}
|
|
|
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
|
}
|
|
|
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current identity: %w", err)
|
|
}
|
|
|
|
content, err := editWithDefaultEditor(
|
|
"nak-group-forum/NOTES_EDITMSG",
|
|
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
|
|
# creating forum topic in group '%s' ('%s''%s')
|
|
# the first line will be used as the topic title
|
|
topic title here
|
|
|
|
# the remaining lines will be the body
|
|
write your forum post
|
|
|
|
# lines starting with '#' are ignored
|
|
`, selfName, selfNpub, groupName, relay, identifier)),
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
title, body, err := parseForumCreateContent(content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
evt := nostr.Event{
|
|
CreatedAt: nostr.Now(),
|
|
Kind: 11,
|
|
Tags: nostr.Tags{
|
|
nostr.Tag{"h", identifier},
|
|
nostr.Tag{"title", title},
|
|
},
|
|
Content: body,
|
|
}
|
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
|
return fmt.Errorf("failed to sign forum topic event: %w", err)
|
|
}
|
|
|
|
r, err := sys.Pool.EnsureRelay(relay)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.Publish(ctx, evt)
|
|
},
|
|
},
|
|
{
|
|
Name: "comment",
|
|
Usage: "comment on a forum topic with a NIP-22 comment event",
|
|
ArgsUsage: "<relay>'<identifier> <id-prefix>",
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
relay, identifier, err := parseGroupIdentifier(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
prefix := strings.TrimSpace(c.Args().Get(1))
|
|
if prefix == "" {
|
|
return fmt.Errorf("missing forum topic id prefix")
|
|
}
|
|
|
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
|
}
|
|
|
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current identity: %w", err)
|
|
}
|
|
|
|
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
topic, err := findEventByPrefix(topics, prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groupName := groupMeta.Name
|
|
if groupName == "" {
|
|
groupName = identifier
|
|
}
|
|
|
|
subject := forumSubjectPreview(topic, 72)
|
|
if subject == "" {
|
|
subject = "<untitled>"
|
|
}
|
|
pm := sys.FetchProfileMetadata(ctx, topic.PubKey)
|
|
headerLines := []string{
|
|
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
|
|
fmt.Sprintf("commenting on forum topic '%s' '%s' by '%s' ('%s') in group '%s' ('%s''%s')", topic.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), groupName, relay, identifier),
|
|
}
|
|
|
|
comments, err := fetchThreadComments(ctx, []string{relay}, topic.ID, nostr.TagMap{"h": []string{identifier}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
edited, err := editWithDefaultEditor(
|
|
"nak-group-forum-reply/NOTES_EDITMSG",
|
|
threadReplyEditorTemplate(ctx, headerLines, topic, comments),
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
content, parentEvt, err := parseThreadReplyContent(topic, comments, edited)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rootRelay := relay
|
|
if topic.Relay.URL != "" {
|
|
rootRelay = topic.Relay.URL
|
|
}
|
|
parentRelay := rootRelay
|
|
if parentEvt.Relay.URL != "" {
|
|
parentRelay = parentEvt.Relay.URL
|
|
}
|
|
|
|
evt := nostr.Event{
|
|
CreatedAt: nostr.Now(),
|
|
Kind: 1111,
|
|
Tags: nostr.Tags{
|
|
nostr.Tag{"E", topic.ID.Hex(), rootRelay},
|
|
nostr.Tag{"e", parentEvt.ID.Hex(), parentRelay},
|
|
nostr.Tag{"P", topic.PubKey.Hex()},
|
|
nostr.Tag{"p", parentEvt.PubKey.Hex()},
|
|
nostr.Tag{"h", identifier},
|
|
nostr.Tag{"K", strconv.Itoa(int(topic.Kind))},
|
|
},
|
|
Content: content,
|
|
}
|
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
|
return fmt.Errorf("failed to sign forum comment event: %w", err)
|
|
}
|
|
|
|
r, err := sys.Pool.EnsureRelay(relay)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.Publish(ctx, evt)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "put-user",
|
|
Usage: "add a user to the group with optional roles",
|
|
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",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "livekit",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "no-livekit",
|
|
},
|
|
&cli.IntSliceFlag{
|
|
Name: "kind",
|
|
Aliases: []string{"supported-kinds"},
|
|
Usage: "list of event kind numbers supported by this group",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "all-kinds",
|
|
Usage: "specify this to delete the supported_kinds property, meaning everything will be supported",
|
|
},
|
|
},
|
|
Action: func(ctx context.Context, c *cli.Command) error {
|
|
relay, identifier, err := parseGroupIdentifier(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.Bool("livekit") || c.Bool("no-livekit") {
|
|
if err := checkRelayLivekitMetadataSupport(ctx, relay); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
group, err := fetchGroupMetadata(ctx, relay, identifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if group.Name == "" {
|
|
group.Name = identifier
|
|
}
|
|
|
|
if name := c.String("name"); name != "" {
|
|
group.Name = name
|
|
}
|
|
if picture := c.String("picture"); picture != "" {
|
|
group.Picture = picture
|
|
}
|
|
if about := c.String("about"); about != "" {
|
|
group.About = about
|
|
}
|
|
if c.Bool("restricted") {
|
|
group.Restricted = true
|
|
} else if c.Bool("unrestricted") {
|
|
group.Restricted = false
|
|
}
|
|
if c.Bool("closed") {
|
|
group.Closed = true
|
|
} else if c.Bool("open") {
|
|
group.Closed = false
|
|
}
|
|
if c.Bool("hidden") {
|
|
group.Hidden = true
|
|
} else if c.Bool("visible") {
|
|
group.Hidden = false
|
|
}
|
|
if c.Bool("private") {
|
|
group.Private = true
|
|
} else if c.Bool("public") {
|
|
group.Private = false
|
|
}
|
|
if c.Bool("livekit") {
|
|
group.LiveKit = true
|
|
} else if c.Bool("no-livekit") {
|
|
group.LiveKit = false
|
|
}
|
|
if supportedKinds := c.IntSlice("kind"); len(supportedKinds) > 0 {
|
|
kinds := make([]nostr.Kind, 0, len(supportedKinds))
|
|
for _, kind := range supportedKinds {
|
|
kinds = append(kinds, nostr.Kind(kind))
|
|
}
|
|
group.SupportedKinds = kinds
|
|
} else if c.Bool("all-kinds") {
|
|
group.SupportedKinds = nil
|
|
}
|
|
|
|
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"name", group.Name})
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"picture", group.Picture})
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"about", group.About})
|
|
if group.Restricted {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
|
|
} else {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
|
}
|
|
if group.Closed {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
|
} else {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
|
}
|
|
if group.Hidden {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
|
} else {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
|
|
}
|
|
if group.Private {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
|
} else {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
|
}
|
|
if group.LiveKit {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"livekit"})
|
|
} else {
|
|
evt.Tags = append(evt.Tags, nostr.Tag{"no-livekit"})
|
|
}
|
|
if group.SupportedKinds != nil {
|
|
tag := make(nostr.Tag, 1, 1+len(group.SupportedKinds))
|
|
tag[0] = "supported_kinds"
|
|
for _, kind := range group.SupportedKinds {
|
|
tag = append(tag, strconv.Itoa(int(kind)))
|
|
}
|
|
evt.Tags = append(evt.Tags, tag)
|
|
}
|
|
return nil
|
|
})
|
|
},
|
|
},
|
|
{
|
|
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
|
|
}
|
|
|
|
func fetchGroupMetadata(ctx context.Context, relay string, identifier string) (nip29.Group, error) {
|
|
group := nip29.Group{}
|
|
|
|
filter := nostr.Filter{
|
|
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
|
|
Tags: nostr.TagMap{"d": []string{identifier}},
|
|
Limit: 1,
|
|
}
|
|
|
|
if info, err := nip11.Fetch(ctx, relay); err == nil {
|
|
if info.Self != nil {
|
|
filter.Authors = append(filter.Authors, *info.Self)
|
|
} else if info.PubKey != nil {
|
|
filter.Authors = append(filter.Authors, *info.PubKey)
|
|
}
|
|
}
|
|
|
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, filter, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
|
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
|
return group, err
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
return group, nil
|
|
}
|
|
|
|
func fetchGroupForumTopics(ctx context.Context, relay string, identifier string) ([]nostr.RelayEvent, error) {
|
|
topics := make([]nostr.RelayEvent, 0, 30)
|
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
|
Kinds: []nostr.Kind{11},
|
|
Tags: nostr.TagMap{"h": []string{identifier}},
|
|
Limit: 500,
|
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
|
topics = append(topics, ie)
|
|
}
|
|
|
|
slices.SortFunc(topics, nostr.CompareRelayEvent)
|
|
return topics, nil
|
|
}
|
|
|
|
func forumSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
|
if tag := evt.Tags.Find("title"); len(tag) >= 2 {
|
|
subject := strings.TrimSpace(tag[1])
|
|
if subject != "" {
|
|
return clampWithEllipsis(subject, maxChars)
|
|
}
|
|
}
|
|
|
|
if tag := evt.Tags.Find("subject"); len(tag) >= 2 {
|
|
subject := strings.TrimSpace(tag[1])
|
|
if subject != "" {
|
|
return clampWithEllipsis(subject, maxChars)
|
|
}
|
|
}
|
|
|
|
for _, line := range strings.Split(evt.Content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
return clampWithEllipsis(line, maxChars)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func parseForumCreateContent(content string) (title string, body string, err error) {
|
|
lines := strings.Split(content, "\n")
|
|
var bodyb strings.Builder
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
if title == "" {
|
|
title = line
|
|
continue
|
|
}
|
|
|
|
bodyb.WriteString(line)
|
|
bodyb.WriteByte('\n')
|
|
}
|
|
|
|
if title == "" {
|
|
return "", "", fmt.Errorf("topic title cannot be empty")
|
|
}
|
|
|
|
body = strings.TrimSpace(bodyb.String())
|
|
return title, body, nil
|
|
}
|
|
|
|
func checkRelayLivekitMetadataSupport(ctx context.Context, relay string) error {
|
|
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create livekit support request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check relay livekit support: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
return fmt.Errorf("relay doesn't advertise livekit support at %s (expected 204, got %d)", url, resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func requestLivekitJWT(ctx context.Context, c *cli.Command, relay string, identifier string) (serverURL string, jwt string, err error) {
|
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit/" + identifier
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create livekit token request: %w", err)
|
|
}
|
|
|
|
tokenEvent := nostr.Event{
|
|
Kind: 27235,
|
|
CreatedAt: nostr.Now(),
|
|
Tags: nostr.Tags{
|
|
{"u", url},
|
|
{"method", "GET"},
|
|
},
|
|
}
|
|
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
|
|
return "", "", fmt.Errorf("failed to sign livekit auth token: %w", err)
|
|
}
|
|
|
|
evtj, _ := stdjson.Marshal(tokenEvent)
|
|
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("livekit token request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed reading livekit token response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 300 {
|
|
msg := strings.TrimSpace(string(body))
|
|
if msg != "" {
|
|
return "", "", fmt.Errorf("livekit token request failed with status %d: %s", resp.StatusCode, msg)
|
|
}
|
|
return "", "", fmt.Errorf("livekit token request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
response := struct {
|
|
ServerURL string `json:"server_url"`
|
|
ParticipantToken string `json:"participant_token"`
|
|
}{}
|
|
if err := stdjson.Unmarshal(body, &response); err != nil {
|
|
return "", "", fmt.Errorf("invalid livekit token response: %w", err)
|
|
}
|
|
|
|
serverURL = response.ServerURL
|
|
jwt = response.ParticipantToken
|
|
|
|
if serverURL == "" || jwt == "" {
|
|
return "", "", fmt.Errorf("livekit token response missing url or jwt")
|
|
}
|
|
|
|
return serverURL, jwt, nil
|
|
}
|