group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now).

This commit is contained in:
fiatjaf
2026-03-05 22:49:23 -03:00
parent 61a3b89d08
commit f59c8a670d
3 changed files with 194 additions and 12 deletions

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260304030104-1d14e6bebe83
fiatjaf.com/nostr v0.0.0-20260306014620-163e59e1f19c
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6

4
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260304030104-1d14e6bebe83 h1:6pX4gFT6N7AoxeLcV3DzJAJCvAR1iPR2tcfInPV+7Ak=
fiatjaf.com/nostr v0.0.0-20260304030104-1d14e6bebe83/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM=
fiatjaf.com/nostr v0.0.0-20260306014620-163e59e1f19c h1:MybCUlYp81e6zdmn74cL0cRHtuQfIukjFWDcohGbah4=
fiatjaf.com/nostr v0.0.0-20260306014620-163e59e1f19c/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM=
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=

200
group.go
View File

@@ -2,12 +2,17 @@ package main
import (
"context"
"encoding/base64"
stdjson "encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip29"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
@@ -33,15 +38,9 @@ var group = &cli.Command{
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
group, err := fetchGroupMetadata(ctx, relay, identifier)
if err != nil {
return err
}
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
@@ -68,6 +67,16 @@ var group = &cli.Command{
", "+
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"),
)
stdout("no-text:",
color.HiBlueString("%s", cond(group.NoText, "yes", "no"))+
", "+
cond(group.NoText, "group is intended for live audio/video only", "text messages are expected to be supported"),
)
return nil
},
},
@@ -327,6 +336,35 @@ var group = &cli.Command{
},
},
},
{
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))
return nil
},
},
{
Name: "forum",
Usage: "read group forum posts",
@@ -436,8 +474,31 @@ var group = &cli.Command{
&cli.BoolFlag{
Name: "public",
},
&cli.BoolFlag{
Name: "livekit",
},
&cli.BoolFlag{
Name: "no-livekit",
},
&cli.BoolFlag{
Name: "no-text",
},
&cli.BoolFlag{
Name: "text",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Bool("livekit") || c.Bool("no-livekit") || c.Bool("no-text") || c.Bool("text") {
relay, _, err := parseGroupIdentifier(c)
if err != nil {
return err
}
if err := checkRelayLivekitMetadataSupport(ctx, relay); err != nil {
return err
}
}
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})
@@ -468,6 +529,16 @@ var group = &cli.Command{
} else if c.Bool("public") {
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
}
if c.Bool("livekit") {
evt.Tags = append(evt.Tags, nostr.Tag{"livekit"})
} else if c.Bool("no-livekit") {
evt.Tags = append(evt.Tags, nostr.Tag{"no-livekit"})
}
if c.Bool("no-text") {
evt.Tags = append(evt.Tags, nostr.Tag{"no-text"})
} else if c.Bool("text") {
evt.Tags = append(evt.Tags, nostr.Tag{"text"})
}
return nil
})
},
@@ -583,3 +654,114 @@ func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err
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}},
}
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 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
}