bridgebot.go raw

   1  package app
   2  
   3  import (
   4  	"context"
   5  	"fmt"
   6  	"os"
   7  	"path/filepath"
   8  	"strings"
   9  	"sync"
  10  	"time"
  11  
  12  	"git.smesh.lol/orly/pkg/bridge"
  13  	"git.smesh.lol/orly/pkg/lol/log"
  14  	"git.smesh.lol/orly/pkg/nostr/encoders/event"
  15  	"git.smesh.lol/orly/pkg/nostr/encoders/hex"
  16  	"git.smesh.lol/orly/pkg/nostr/encoders/tag"
  17  	"git.smesh.lol/orly/pkg/nostr/interfaces/signer/p8k"
  18  	"git.smesh.lol/orly/pkg/nostr/protocol/marmot"
  19  	"git.smesh.lol/orly/pkg/nostr/ws"
  20  )
  21  
  22  // BridgeBot is a server-side marmot client that receives DMs and processes
  23  // subscription commands (status, subscribe, subscribe <alias>).
  24  type BridgeBot struct {
  25  	client  *marmot.Client
  26  	handler *bridge.SubscriptionHandler
  27  	relay   *ws.Client
  28  	adapter *marmot.WSRelayAdapter
  29  	sign    *p8k.Signer
  30  	cancel  context.CancelFunc
  31  	wg      sync.WaitGroup
  32  }
  33  
  34  // NewBridgeBot creates a bridge bot that connects to the given relay.
  35  // If freeMode is true, subscriptions activate without payment.
  36  // dataDir is used to persist the bot's keypair across restarts.
  37  func NewBridgeBot(ctx context.Context, relayURL string, freeMode bool, dataDir string) (*BridgeBot, error) {
  38  	sign, err := p8k.New()
  39  	if err != nil {
  40  		return nil, err
  41  	}
  42  	if err := loadOrGenerateKey(sign, dataDir); err != nil {
  43  		return nil, err
  44  	}
  45  
  46  	relay, err := ws.RelayConnect(ctx, relayURL)
  47  	if err != nil {
  48  		return nil, err
  49  	}
  50  
  51  	adapter := marmot.NewWSRelayAdapter(relay)
  52  	client, err := marmot.NewClient(&marmot.LocalCrypto{Sign: sign}, marmot.NewMemoryGroupStore(), adapter, relayURL)
  53  	if err != nil {
  54  		relay.Close()
  55  		return nil, err
  56  	}
  57  
  58  	subStore := bridge.NewMemorySubscriptionStore()
  59  	var payments *bridge.PaymentProcessor
  60  	if !freeMode {
  61  		payments = bridge.NewPaymentProcessorWithClient(newAutoPayNWC(), 1000)
  62  	}
  63  
  64  	bot := &BridgeBot{
  65  		client:  client,
  66  		relay:   relay,
  67  		adapter: adapter,
  68  		sign:    sign,
  69  	}
  70  
  71  	sendDM := func(pubkeyHex string, content string) error {
  72  		pub, err := hex.Dec(pubkeyHex)
  73  		if err != nil {
  74  			return err
  75  		}
  76  		return client.SendDM(ctx, pub, []byte(content))
  77  	}
  78  
  79  	bot.handler = bridge.NewSubscriptionHandler(subStore, payments, sendDM, 1000, nil, 2000, "")
  80  	return bot, nil
  81  }
  82  
  83  // Start publishes the bot's key package and begins listening for DMs.
  84  func (b *BridgeBot) Start(ctx context.Context) error {
  85  	ctx, b.cancel = context.WithCancel(ctx)
  86  
  87  	if err := b.client.PublishKeyPackage(ctx); err != nil {
  88  		return err
  89  	}
  90  	log.I.F("bridge-bot: key package published, pubkey=%s", b.PubkeyHex())
  91  
  92  	// Publish kind-0 metadata so the frontend can display the bot's name.
  93  	metaEv := &event.E{
  94  		CreatedAt: time.Now().Unix(),
  95  		Kind:      0,
  96  		Tags:      tag.NewS(),
  97  		Content:   []byte(`{"name":"smesh bridge","about":"MLS DM bridge bot"}`),
  98  	}
  99  	if err := metaEv.Sign(b.sign); err != nil {
 100  		log.W.F("bridge-bot: failed to sign kind-0: %v", err)
 101  	} else if err := b.relay.Publish(ctx, metaEv); err != nil {
 102  		log.W.F("bridge-bot: failed to publish kind-0: %v", err)
 103  	}
 104  
 105  	b.client.OnDM(func(senderPub []byte, plaintext []byte) {
 106  		senderHex := hex.Enc(senderPub)
 107  		content := strings.TrimSpace(string(plaintext))
 108  		log.I.F("bridge-bot: DM from %s: %s", senderHex[:16], content)
 109  
 110  		switch {
 111  		case content == "status":
 112  			b.handler.HandleStatus(senderHex)
 113  		case content == "subscribe":
 114  			b.handler.HandleSubscribe(ctx, senderHex, "")
 115  		case strings.HasPrefix(content, "subscribe "):
 116  			alias := strings.TrimSpace(content[10:])
 117  			b.handler.HandleSubscribe(ctx, senderHex, alias)
 118  		default:
 119  			// Echo for debugging
 120  			sendDM := func(pub string, msg string) error {
 121  				p, _ := hex.Dec(pub)
 122  				return b.client.SendDM(ctx, p, []byte(msg))
 123  			}
 124  			sendDM(senderHex, "echo: "+content)
 125  		}
 126  	})
 127  
 128  	b.wg.Add(1)
 129  	go b.eventLoop(ctx)
 130  	return nil
 131  }
 132  
 133  func (b *BridgeBot) eventLoop(ctx context.Context) {
 134  	defer b.wg.Done()
 135  	for {
 136  		filters := b.client.SubscriptionFilters()
 137  		stream, err := b.adapter.Subscribe(ctx, filters)
 138  		if err != nil {
 139  			log.W.F("bridge-bot: subscribe failed: %v", err)
 140  			return
 141  		}
 142  
 143  		done := make(chan struct{})
 144  		go func() {
 145  			defer close(done)
 146  			for ev := range stream.Events() {
 147  				if err := b.client.HandleEvent(ctx, ev); err != nil {
 148  					log.W.F("bridge-bot: handle event: %v", err)
 149  				}
 150  			}
 151  		}()
 152  
 153  		select {
 154  		case <-ctx.Done():
 155  			stream.Close()
 156  			return
 157  		case <-b.client.GroupsChanged():
 158  			log.I.F("bridge-bot: groups changed, re-subscribing")
 159  			stream.Close()
 160  			<-done
 161  		}
 162  	}
 163  }
 164  
 165  // Stop shuts down the bridge bot.
 166  func (b *BridgeBot) Stop() {
 167  	if b.cancel != nil {
 168  		b.cancel()
 169  	}
 170  	b.wg.Wait()
 171  	if b.relay != nil {
 172  		b.relay.Close()
 173  	}
 174  }
 175  
 176  // PubkeyHex returns the bot's public key in hex.
 177  func (b *BridgeBot) PubkeyHex() string {
 178  	return hex.Enc(b.sign.Pub())
 179  }
 180  
 181  // loadOrGenerateKey loads a persisted keypair from dataDir/bridgebot.nsec,
 182  // or generates a new one and saves it. Supports ORLY_BRIDGE_BOT_NSEC env override.
 183  func loadOrGenerateKey(sign *p8k.Signer, dataDir string) error {
 184  	// Env var override — highest priority.
 185  	if nsecHex := os.Getenv("ORLY_BRIDGE_BOT_NSEC"); nsecHex != "" {
 186  		sec, err := hex.Dec(nsecHex)
 187  		if err != nil || len(sec) != 32 {
 188  			return fmt.Errorf("invalid ORLY_BRIDGE_BOT_NSEC")
 189  		}
 190  		return sign.InitSec(sec)
 191  	}
 192  
 193  	// Try loading from file.
 194  	if dataDir != "" {
 195  		nsecPath := filepath.Join(dataDir, "bridgebot.nsec")
 196  		if data, err := os.ReadFile(nsecPath); err == nil {
 197  			sec, err := hex.Dec(strings.TrimSpace(string(data)))
 198  			if err == nil && len(sec) == 32 {
 199  				log.I.F("bridge-bot: loaded key from %s", nsecPath)
 200  				return sign.InitSec(sec)
 201  			}
 202  			log.W.F("bridge-bot: corrupt nsec file %s, generating new key", nsecPath)
 203  		}
 204  	}
 205  
 206  	// Generate new key and persist.
 207  	if err := sign.Generate(); err != nil {
 208  		return err
 209  	}
 210  	if dataDir != "" {
 211  		nsecPath := filepath.Join(dataDir, "bridgebot.nsec")
 212  		os.MkdirAll(dataDir, 0700)
 213  		if err := os.WriteFile(nsecPath, []byte(hex.Enc(sign.Sec())), 0600); err != nil {
 214  			log.W.F("bridge-bot: failed to save key: %v", err)
 215  		} else {
 216  			log.I.F("bridge-bot: saved new key to %s", nsecPath)
 217  		}
 218  	}
 219  	return nil
 220  }
 221