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