From f59c8a670dbdbd0e53bc7e459b5ad84c712dcba4 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Thu, 5 Mar 2026 22:49:23 -0300 Subject: [PATCH] group: editing groups with livekit metadata and getting jwt from the server (can be used with meet.livekit.io for now). --- go.mod | 2 +- go.sum | 4 +- group.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 194 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 20b6193..6258f12 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-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 diff --git a/go.sum b/go.sum index cdb1fc7..b28af71 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/group.go b/group.go index b84f8e7..8c764ea 100644 --- a/group.go +++ b/group.go @@ -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: "'", + 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 +}