Skip to content

Commit 9bc5096

Browse files
committed
feat(bunker): add interactive NostrConnect connect command support to bunker
1 parent 7d78273 commit 9bc5096

2 files changed

Lines changed: 221 additions & 79 deletions

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,31 @@ listening at [wss://relay.damus.io wss://nos.lol wss://relay.nsecbunker.com]:
177177
bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Fnos.lol&relay=wss%3A%2F%2Frelay.nsecbunker.com&secret=XuuiMbcLwuwL
178178
```
179179
180-
you can also display a QR code for the bunker URI by adding the `--qrcode` flag:
180+
#### Bunker subcommands
181+
182+
Bunker has a few subcommands that you can use to manage it, type `help` to see them all:
183+
```shell
184+
~> ./nak bunker relay.nsec.app
185+
wss://relay.nsec.app... ok.
186+
listening at [wss://relay.nsec.app]:
187+
pubkey: f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a
188+
npub: npub17kv3rdtpcd7fpvq7newz24eswwqgxhyr8xt4daxk9kqkwgn7gg9q4gy8vf
189+
to restart: nak bunker relay.nsec.app
190+
bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.nsec.app&secret=cAMoUOddVMla
191+
192+
--------------- Bunker Command Interface ---------------
193+
Type 'help' for available commands or 'exit' to quit.
194+
--------------------------------------------------------
195+
help
196+
Available Commands:
197+
help, h, ? - Show this help message
198+
info, i - Display current bunker information
199+
qr - Generate and display QR code for the bunker URI
200+
connect, c <nostrconnect://uri> - Connect to a remote client using nostrconnect:// URI
201+
exit, quit, q - Shutdown the bunker
202+
```
203+
204+
You can also display a QR code for the bunker URI by adding the `--qrcode` flag:
181205
182206
```shell
183207
~> nak bunker --qrcode --sec ncryptsec1... relay.damus.io

bunker.go

Lines changed: 196 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"encoding/hex"
@@ -248,6 +249,15 @@ var bunker = &cli.Command{
248249
pubkey := sec.Public()
249250
npub := nip19.EncodeNpub(pubkey)
250251

252+
// printQR generates and prints the QR code for the bunker URI
253+
printQR := func() {
254+
qs.Set("secret", newSecret)
255+
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
256+
log("\nQR Code for bunker URI:\n")
257+
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
258+
log("\n\n")
259+
}
260+
251261
// this function will be called every now and then
252262
printBunkerInfo := func() {
253263
qs.Set("secret", newSecret)
@@ -331,9 +341,7 @@ var bunker = &cli.Command{
331341

332342
// print QR code if requested
333343
if c.Bool("qrcode") {
334-
log("QR Code for bunker URI:\n")
335-
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
336-
log("\n\n")
344+
printQR()
337345
}
338346
}
339347
printBunkerInfo()
@@ -348,40 +356,51 @@ var bunker = &cli.Command{
348356

349357
signer := nip46.NewStaticKeySigner(sec)
350358

351-
// unix socket nostrconnect:// handling
352-
go func() {
353-
for uri := range onSocketConnect(ctx, c) {
354-
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
355-
if err != nil {
356-
continue
357-
}
358-
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey), uri.String())
359-
360-
relays := uri.Query()["relay"]
361-
362-
// pre-authorize this client since the user has explicitly added it
363-
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
364-
return c.PubKey == clientPublicKey
365-
}) {
366-
config.Clients = append(config.Clients, BunkerConfigClient{
367-
PubKey: clientPublicKey,
368-
Name: uri.Query().Get("name"),
369-
URL: uri.Query().Get("url"),
370-
Icon: uri.Query().Get("icon"),
371-
CustomRelays: relays,
372-
})
373-
}
359+
// common help to handle nostrconnect:// URIs
360+
handleNostrConnect := func(uri *url.URL) {
361+
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
362+
if err != nil {
363+
log("* invalid nostrconnect:// URI: %s\n", err)
364+
return
365+
}
366+
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey), uri.String())
367+
368+
relays := uri.Query()["relay"]
369+
370+
// pre-authorize this client since the user has explicitly added it
371+
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
372+
return c.PubKey == clientPublicKey
373+
}) {
374+
config.Clients = append(config.Clients, BunkerConfigClient{
375+
PubKey: clientPublicKey,
376+
Name: uri.Query().Get("name"),
377+
URL: uri.Query().Get("url"),
378+
Icon: uri.Query().Get("icon"),
379+
CustomRelays: relays,
380+
})
381+
}
374382

375-
if persist != nil {
376-
persist()
377-
}
383+
if persist != nil {
384+
persist()
385+
}
378386

379-
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
380-
if err != nil {
381-
log("* failed to handle: %s\n", err)
382-
continue
387+
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
388+
if err != nil {
389+
log("* failed to handle: %s\n", err)
390+
return
391+
}
392+
393+
// compute new custom relays to avoid duplicate subscriptions
394+
newCustomRelays := make([]string, 0, len(relays))
395+
for _, r := range relays {
396+
if !slices.Contains(allRelays, r) {
397+
newCustomRelays = append(newCustomRelays, r)
398+
allRelays = append(allRelays, r)
383399
}
400+
}
384401

402+
if len(newCustomRelays) > 0 {
403+
log("subscribing to %d new relays: %s\n", len(newCustomRelays), strings.Join(newCustomRelays, ","))
385404
go func() {
386405
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
387406
Kinds: []nostr.Kind{nostr.KindNostrConnect},
@@ -394,16 +413,24 @@ var bunker = &cli.Command{
394413
}()
395414

396415
time.Sleep(time.Millisecond * 25)
397-
jresp, _ := json.MarshalIndent(resp, "", " ")
398-
log("~ responding with %s\n", string(jresp))
399-
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
400-
if res.Error == nil {
401-
log("* sent through %s\n", res.Relay.URL)
402-
} else {
403-
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
404-
}
416+
}
417+
418+
jresp, _ := json.MarshalIndent(resp, "", " ")
419+
log("~ responding with %s\n", string(jresp))
420+
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
421+
if res.Error == nil {
422+
log("* sent through %s\n", res.Relay.URL)
423+
} else {
424+
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
405425
}
406426
}
427+
}
428+
429+
// unix socket nostrconnect:// handling
430+
go func() {
431+
for uri := range onSocketConnect(ctx, c) {
432+
handleNostrConnect(uri)
433+
}
407434
}()
408435

409436
// just a gimmick
@@ -440,54 +467,145 @@ var bunker = &cli.Command{
440467
return false
441468
}
442469

443-
for ie := range events {
444-
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
470+
// == SUBCOMMANDS ==
471+
472+
exitChan := make(chan bool, 1)
473+
474+
// printHelp displays available commands for the bunker interface
475+
printHelp := func() {
476+
log("%s\n", color.CyanString("Available Commands:"))
477+
log(" %s - Show this help message\n", color.GreenString("help, h, ?"))
478+
log(" %s - Display current bunker information\n", color.GreenString("info, i"))
479+
log(" %s - Generate and display QR code for the bunker URI\n", color.GreenString("qr"))
480+
log(" %s - Connect to a remote client using nostrconnect:// URI\n", color.GreenString("connect, c <nostrconnect://uri>"))
481+
log(" %s - Shutdown the bunker\n", color.GreenString("exit, quit, q"))
482+
log("\n")
483+
}
484+
485+
// handleConnectCommand processes nostrconnect:// URIs for interactive connection flow
486+
handleConnectCommand := func(connectURI string) {
487+
if !strings.HasPrefix(connectURI, "nostrconnect://") {
488+
log("Error: URI must start with nostrconnect://\n")
489+
return
490+
}
445491

446-
// handle the NIP-46 request event
447-
from := ie.Event.PubKey
448-
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
492+
// Parse the nostrconnect URI
493+
u, err := url.Parse(connectURI)
449494
if err != nil {
450-
log("< failed to handle request from %s: %s\n", from, err.Error())
451-
continue
495+
log("Error: Invalid nostrconnect URI: %v\n", err)
496+
return
452497
}
453498

454-
jreq, _ := json.MarshalIndent(req, "", " ")
455-
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
456-
jresp, _ := json.MarshalIndent(resp, "", " ")
457-
log("~ responding with %s\n", string(jresp))
499+
handleNostrConnect(u)
500+
}
458501

459-
// use custom relays if they are defined for this client
460-
// (normally if the initial connection came from a nostrconnect:// URL)
461-
relays := relayURLs
462-
for _, c := range config.Clients {
463-
if c.PubKey == from && len(c.CustomRelays) > 0 {
464-
relays = c.CustomRelays
465-
break
466-
}
502+
// handleBunkerCommand processes user commands in the bunker interface
503+
handleBunkerCommand := func(command string) {
504+
parts := strings.Fields(command)
505+
if len(parts) == 0 {
506+
return
467507
}
468508

469-
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
470-
if res.Error == nil {
471-
log("* sent response through %s\n", res.Relay.URL)
472-
} else {
473-
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
509+
switch strings.ToLower(parts[0]) {
510+
case "help", "h", "?":
511+
printHelp()
512+
case "info", "i":
513+
printBunkerInfo()
514+
case "qr":
515+
printQR()
516+
case "connect", "c":
517+
if len(parts) < 2 {
518+
log("Usage: connect <nostrconnect://uri>\n")
519+
return
474520
}
521+
handleConnectCommand(parts[1])
522+
case "exit", "quit", "q":
523+
log("Exit command received.\n")
524+
exitChan <- true
525+
case "":
526+
// Ignore empty commands
527+
default:
528+
log("Unknown command: %s. Type 'help' for available commands.\n", command)
529+
}
530+
}
531+
532+
// Start command input handler in a separate goroutine
533+
go func() {
534+
scanner := bufio.NewScanner(os.Stdin)
535+
for scanner.Scan() {
536+
command := strings.TrimSpace(scanner.Text())
537+
handleBunkerCommand(command)
475538
}
539+
if err := scanner.Err(); err != nil {
540+
log("error reading command: %v\n", err)
541+
}
542+
}()
476543

477-
// just after handling one request we trigger this
478-
go func() {
479-
ctx, cancel := context.WithCancel(ctx)
480-
defer cancel()
481-
cancelPreviousBunkerInfoPrint = cancel
482-
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
483-
// but we will only do if the bunker is inactive for more than 5 minutes
484-
select {
485-
case <-ctx.Done():
486-
case <-time.After(time.Minute * 5):
487-
log("\n")
488-
printBunkerInfo()
544+
// Print initial command help
545+
log("%s\nType 'help' for available commands or 'exit' to quit.\n%s\n",
546+
color.CyanString("--------------- Bunker Command Interface ---------------"),
547+
color.CyanString("--------------------------------------------------------"))
548+
549+
// == END OF SUBCOMMANDS ==
550+
551+
for {
552+
// Check if exit was requested first
553+
select {
554+
case <-exitChan:
555+
log("Shutting down bunker...\n")
556+
return nil
557+
case ie := <-events:
558+
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
559+
560+
// handle the NIP-46 request event
561+
from := ie.Event.PubKey
562+
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
563+
if err != nil {
564+
log("< failed to handle request from %s: %s\n", from, err.Error())
565+
continue
566+
}
567+
568+
jreq, _ := json.MarshalIndent(req, "", " ")
569+
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
570+
jresp, _ := json.MarshalIndent(resp, "", " ")
571+
log("~ responding with %s\n", string(jresp))
572+
573+
// use custom relays if they are defined for this client
574+
// (normally if the initial connection came from a nostrconnect:// URL)
575+
relays := relayURLs
576+
for _, c := range config.Clients {
577+
if c.PubKey == from && len(c.CustomRelays) > 0 {
578+
relays = c.CustomRelays
579+
break
580+
}
489581
}
490-
}()
582+
583+
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
584+
if res.Error == nil {
585+
log("* sent response through %s\n", res.Relay.URL)
586+
} else {
587+
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
588+
}
589+
}
590+
591+
// just after handling one request we trigger this
592+
go func() {
593+
ctx, cancel := context.WithCancel(ctx)
594+
defer cancel()
595+
cancelPreviousBunkerInfoPrint = cancel
596+
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
597+
// but we will only do if the bunker is inactive for more than 5 minutes
598+
select {
599+
case <-ctx.Done():
600+
case <-time.After(time.Minute * 5):
601+
log("\n")
602+
printBunkerInfo()
603+
}
604+
}()
605+
case <-time.After(100 * time.Millisecond):
606+
// Continue to check for exit signal even when no events
607+
continue
608+
}
491609
}
492610

493611
return nil

0 commit comments

Comments
 (0)