Skip to content

Commit 0c8735c

Browse files
committed
feat(bunker): add interactive NostrConnect connect command support to bunker
1 parent ba9a5ba commit 0c8735c

2 files changed

Lines changed: 224 additions & 82 deletions

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,31 @@ listening at [wss://relay.damus.io wss://nos.lol wss://relay.nsecbunker.com]:
187187
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
188188
```
189189
190-
you can also display a QR code for the bunker URI by adding the `--qrcode` flag:
190+
#### Bunker subcommands
191+
192+
Bunker has a few subcommands that you can use to manage it, type `help` to see them all:
193+
```shell
194+
~> ./nak bunker relay.nsec.app
195+
wss://relay.nsec.app... ok.
196+
listening at [wss://relay.nsec.app]:
197+
pubkey: f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a
198+
npub: npub17kv3rdtpcd7fpvq7newz24eswwqgxhyr8xt4daxk9kqkwgn7gg9q4gy8vf
199+
to restart: nak bunker relay.nsec.app
200+
bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.nsec.app&secret=cAMoUOddVMla
201+
202+
--------------- Bunker Command Interface ---------------
203+
Type 'help' for available commands or 'exit' to quit.
204+
--------------------------------------------------------
205+
help
206+
Available Commands:
207+
help, h, ? - Show this help message
208+
info, i - Display current bunker information
209+
qr - Generate and display QR code for the bunker URI
210+
connect, c <nostrconnect://uri> - Connect to a remote client using nostrconnect:// URI
211+
exit, quit, q - Shutdown the bunker
212+
```
213+
214+
You can also display a QR code for the bunker URI by adding the `--qrcode` flag:
191215
192216
```shell
193217
~> nak bunker --qrcode --sec ncryptsec1... relay.damus.io

bunker.go

Lines changed: 199 additions & 81 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"
@@ -249,6 +250,15 @@ var bunker = &cli.Command{
249250
pubkey := sec.Public()
250251
npub := nip19.EncodeNpub(pubkey)
251252

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

333343
// print QR code if requested
334344
if c.Bool("qrcode") {
335-
log("QR Code for bunker URI:\n")
336-
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
337-
log("\n\n")
345+
printQR()
338346
}
339347
}
340348
printBunkerInfo()
@@ -350,40 +358,51 @@ var bunker = &cli.Command{
350358
signer := nip46.NewStaticKeySigner(sec)
351359
signer.DefaultRelays = config.Relays
352360

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

377-
if persist != nil {
378-
persist()
379-
}
385+
if persist != nil {
386+
persist()
387+
}
380388

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

404+
if len(newCustomRelays) > 0 {
405+
log("subscribing to %d new relays: %s\n", len(newCustomRelays), strings.Join(newCustomRelays, ","))
387406
go func() {
388407
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
389408
Kinds: []nostr.Kind{nostr.KindNostrConnect},
@@ -396,16 +415,24 @@ var bunker = &cli.Command{
396415
}()
397416

398417
time.Sleep(time.Millisecond * 25)
399-
jresp, _ := json.MarshalIndent(resp, "", " ")
400-
log("~ responding with %s\n", string(jresp))
401-
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
402-
if res.Error == nil {
403-
log("* sent through %s\n", res.Relay.URL)
404-
} else {
405-
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
406-
}
418+
}
419+
420+
jresp, _ := json.MarshalIndent(resp, "", " ")
421+
log("~ responding with %s\n", string(jresp))
422+
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
423+
if res.Error == nil {
424+
log("* sent through %s\n", res.Relay.URL)
425+
} else {
426+
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
407427
}
408428
}
429+
}
430+
431+
// unix socket nostrconnect:// handling
432+
go func() {
433+
for uri := range onSocketConnect(ctx, c) {
434+
handleNostrConnect(uri)
435+
}
409436
}()
410437

411438
// just a gimmick
@@ -442,58 +469,149 @@ var bunker = &cli.Command{
442469
return false
443470
}
444471

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

448-
// handle the NIP-46 request event
449-
from := ie.Event.PubKey
450-
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
494+
// Parse the nostrconnect URI
495+
u, err := url.Parse(connectURI)
451496
if err != nil {
452-
if errors.Is(err, nip46.AlreadyHandled) {
453-
continue
497+
log("Error: Invalid nostrconnect URI: %v\n", err)
498+
return
499+
}
500+
501+
handleNostrConnect(u)
502+
}
503+
504+
// handleBunkerCommand processes user commands in the bunker interface
505+
handleBunkerCommand := func(command string) {
506+
parts := strings.Fields(command)
507+
if len(parts) == 0 {
508+
return
509+
}
510+
511+
switch strings.ToLower(parts[0]) {
512+
case "help", "h", "?":
513+
printHelp()
514+
case "info", "i":
515+
printBunkerInfo()
516+
case "qr":
517+
printQR()
518+
case "connect", "c":
519+
if len(parts) < 2 {
520+
log("Usage: connect <nostrconnect://uri>\n")
521+
return
454522
}
523+
handleConnectCommand(parts[1])
524+
case "exit", "quit", "q":
525+
log("Exit command received.\n")
526+
exitChan <- true
527+
case "":
528+
// Ignore empty commands
529+
default:
530+
log("Unknown command: %s. Type 'help' for available commands.\n", command)
531+
}
532+
}
455533

456-
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
457-
continue
534+
// Start command input handler in a separate goroutine
535+
go func() {
536+
scanner := bufio.NewScanner(os.Stdin)
537+
for scanner.Scan() {
538+
command := strings.TrimSpace(scanner.Text())
539+
handleBunkerCommand(command)
540+
}
541+
if err := scanner.Err(); err != nil {
542+
log("error reading command: %v\n", err)
458543
}
544+
}()
459545

460-
jreq, _ := json.MarshalIndent(req, "", " ")
461-
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
462-
jresp, _ := json.MarshalIndent(resp, "", " ")
463-
log("~ responding with %s\n", string(jresp))
546+
// Print initial command help
547+
log("%s\nType 'help' for available commands or 'exit' to quit.\n%s\n",
548+
color.CyanString("--------------- Bunker Command Interface ---------------"),
549+
color.CyanString("--------------------------------------------------------"))
464550

465-
// use custom relays if they are defined for this client
466-
// (normally if the initial connection came from a nostrconnect:// URL)
467-
relays := relayURLs
468-
for _, c := range config.Clients {
469-
if c.PubKey == from && len(c.CustomRelays) > 0 {
470-
relays = c.CustomRelays
471-
break
551+
// == END OF SUBCOMMANDS ==
552+
553+
for {
554+
// Check if exit was requested first
555+
select {
556+
case <-exitChan:
557+
log("Shutting down bunker...\n")
558+
return nil
559+
case ie := <-events:
560+
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
561+
562+
// handle the NIP-46 request event
563+
from := ie.Event.PubKey
564+
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
565+
if err != nil {
566+
if errors.Is(err, nip46.AlreadyHandled) {
567+
continue
472568
}
473-
}
474569

475-
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
476-
if res.Error == nil {
477-
log("* sent response through %s\n", res.Relay.URL)
478-
} else {
479-
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
570+
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
571+
continue
480572
}
481-
}
482573

483-
// just after handling one request we trigger this
484-
go func() {
485-
ctx, cancel := context.WithCancel(ctx)
486-
defer cancel()
487-
cancelPreviousBunkerInfoPrint = cancel
488-
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
489-
// but we will only do if the bunker is inactive for more than 5 minutes
490-
select {
491-
case <-ctx.Done():
492-
case <-time.After(time.Minute * 5):
493-
log("\n")
494-
printBunkerInfo()
574+
jreq, _ := json.MarshalIndent(req, "", " ")
575+
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
576+
jresp, _ := json.MarshalIndent(resp, "", " ")
577+
log("~ responding with %s\n", string(jresp))
578+
579+
// use custom relays if they are defined for this client
580+
// (normally if the initial connection came from a nostrconnect:// URL)
581+
relays := relayURLs
582+
for _, c := range config.Clients {
583+
if c.PubKey == from && len(c.CustomRelays) > 0 {
584+
relays = c.CustomRelays
585+
break
586+
}
495587
}
496-
}()
588+
589+
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
590+
if res.Error == nil {
591+
log("* sent response through %s\n", res.Relay.URL)
592+
} else {
593+
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
594+
}
595+
}
596+
597+
// just after handling one request we trigger this
598+
go func() {
599+
ctx, cancel := context.WithCancel(ctx)
600+
defer cancel()
601+
cancelPreviousBunkerInfoPrint = cancel
602+
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
603+
// but we will only do if the bunker is inactive for more than 5 minutes
604+
select {
605+
case <-ctx.Done():
606+
case <-time.After(time.Minute * 5):
607+
log("\n")
608+
printBunkerInfo()
609+
}
610+
}()
611+
case <-time.After(100 * time.Millisecond):
612+
// Continue to check for exit signal even when no events
613+
continue
614+
}
497615
}
498616

499617
return nil

0 commit comments

Comments
 (0)