Skip to content

Commit b269ff9

Browse files
committed
feat(bunker): add interactive NostrConnect connect command support to bunker
1 parent 037e8ef commit b269ff9

File tree

2 files changed

+224
-82
lines changed

2 files changed

+224
-82
lines changed

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
@@ -449,58 +476,149 @@ var bunker = &cli.Command{
449476
return false
450477
}
451478

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

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

463-
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
464-
continue
541+
// Start command input handler in a separate goroutine
542+
go func() {
543+
scanner := bufio.NewScanner(os.Stdin)
544+
for scanner.Scan() {
545+
command := strings.TrimSpace(scanner.Text())
546+
handleBunkerCommand(command)
547+
}
548+
if err := scanner.Err(); err != nil {
549+
log("error reading command: %v\n", err)
465550
}
551+
}()
466552

467-
jreq, _ := json.MarshalIndent(req, "", " ")
468-
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
469-
jresp, _ := json.MarshalIndent(resp, "", " ")
470-
log("~ responding with %s\n", string(jresp))
553+
// Print initial command help
554+
log("%s\nType 'help' for available commands or 'exit' to quit.\n%s\n",
555+
color.CyanString("--------------- Bunker Command Interface ---------------"),
556+
color.CyanString("--------------------------------------------------------"))
471557

472-
// use custom relays if they are defined for this client
473-
// (normally if the initial connection came from a nostrconnect:// URL)
474-
relays := relayURLs
475-
for _, c := range config.Clients {
476-
if c.PubKey == from && len(c.CustomRelays) > 0 {
477-
relays = c.CustomRelays
478-
break
558+
// == END OF SUBCOMMANDS ==
559+
560+
for {
561+
// Check if exit was requested first
562+
select {
563+
case <-exitChan:
564+
log("Shutting down bunker...\n")
565+
return nil
566+
case ie := <-events:
567+
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
568+
569+
// handle the NIP-46 request event
570+
from := ie.Event.PubKey
571+
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
572+
if err != nil {
573+
if errors.Is(err, nip46.AlreadyHandled) {
574+
continue
479575
}
480-
}
481576

482-
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
483-
if res.Error == nil {
484-
log("* sent response through %s\n", res.Relay.URL)
485-
} else {
486-
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
577+
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
578+
continue
487579
}
488-
}
489580

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

506624
return nil

0 commit comments

Comments
 (0)