You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
265 lines
6.4 KiB
265 lines
6.4 KiB
package nwc |
|
|
|
import ( |
|
"context" |
|
"encoding/json" |
|
"errors" |
|
"fmt" |
|
"time" |
|
|
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/log" |
|
"next.orly.dev/pkg/crypto/encryption" |
|
"next.orly.dev/pkg/encoders/event" |
|
"next.orly.dev/pkg/encoders/filter" |
|
"next.orly.dev/pkg/encoders/hex" |
|
"next.orly.dev/pkg/encoders/kind" |
|
"next.orly.dev/pkg/encoders/tag" |
|
"next.orly.dev/pkg/encoders/timestamp" |
|
"next.orly.dev/pkg/interfaces/signer" |
|
"next.orly.dev/pkg/protocol/ws" |
|
"next.orly.dev/pkg/utils/values" |
|
) |
|
|
|
type Client struct { |
|
relay string |
|
clientSecretKey signer.I |
|
walletPublicKey []byte |
|
conversationKey []byte |
|
} |
|
|
|
func NewClient(connectionURI string) (cl *Client, err error) { |
|
var parts *ConnectionParams |
|
if parts, err = ParseConnectionURI(connectionURI); chk.E(err) { |
|
return |
|
} |
|
cl = &Client{ |
|
relay: parts.relay, |
|
clientSecretKey: parts.clientSecretKey, |
|
walletPublicKey: parts.walletPublicKey, |
|
conversationKey: parts.conversationKey, |
|
} |
|
return |
|
} |
|
|
|
func (cl *Client) Request( |
|
c context.Context, method string, params, result any, |
|
) (err error) { |
|
ctx, cancel := context.WithTimeout(c, 10*time.Second) |
|
defer cancel() |
|
|
|
request := map[string]any{"method": method} |
|
if params != nil { |
|
request["params"] = params |
|
} |
|
|
|
var req []byte |
|
if req, err = json.Marshal(request); chk.E(err) { |
|
return |
|
} |
|
|
|
var content string |
|
if content, err = encryption.Encrypt(cl.conversationKey, req, nil); chk.E(err) { |
|
return |
|
} |
|
|
|
ev := &event.E{ |
|
Content: []byte(content), |
|
CreatedAt: time.Now().Unix(), |
|
Kind: 23194, |
|
Tags: tag.NewS( |
|
tag.NewFromAny("encryption", "nip44_v2"), |
|
tag.NewFromAny("p", hex.Enc(cl.walletPublicKey)), |
|
), |
|
} |
|
|
|
if err = ev.Sign(cl.clientSecretKey); chk.E(err) { |
|
return |
|
} |
|
|
|
var rc *ws.Client |
|
if rc, err = ws.RelayConnect(ctx, cl.relay); chk.E(err) { |
|
return |
|
} |
|
defer rc.Close() |
|
|
|
var sub *ws.Subscription |
|
if sub, err = rc.Subscribe( |
|
ctx, filter.NewS( |
|
&filter.F{ |
|
Limit: values.ToUintPointer(1), |
|
Kinds: kind.NewS(kind.New(23195)), |
|
Since: ×tamp.T{V: time.Now().Unix()}, |
|
}, |
|
), |
|
); chk.E(err) { |
|
return |
|
} |
|
defer sub.Unsub() |
|
|
|
if err = rc.Publish(ctx, ev); chk.E(err) { |
|
return fmt.Errorf("publish failed: %w", err) |
|
} |
|
|
|
select { |
|
case <-ctx.Done(): |
|
return fmt.Errorf("no response from wallet (connection may be inactive)") |
|
case e := <-sub.Events: |
|
if e == nil { |
|
return fmt.Errorf("subscription closed (wallet connection inactive)") |
|
} |
|
if len(e.Content) == 0 { |
|
return fmt.Errorf("empty response content") |
|
} |
|
var raw string |
|
if raw, err = encryption.Decrypt( |
|
cl.conversationKey, string(e.Content), |
|
); chk.E(err) { |
|
return fmt.Errorf( |
|
"decryption failed (invalid conversation key): %w", err, |
|
) |
|
} |
|
|
|
var resp map[string]any |
|
if err = json.Unmarshal([]byte(raw), &resp); chk.E(err) { |
|
return |
|
} |
|
|
|
if errData, ok := resp["error"].(map[string]any); ok { |
|
code, _ := errData["code"].(string) |
|
msg, _ := errData["message"].(string) |
|
return fmt.Errorf("%s: %s", code, msg) |
|
} |
|
|
|
if result != nil && resp["result"] != nil { |
|
var resultBytes []byte |
|
if resultBytes, err = json.Marshal(resp["result"]); chk.E(err) { |
|
return |
|
} |
|
if err = json.Unmarshal(resultBytes, result); chk.E(err) { |
|
return |
|
} |
|
} |
|
} |
|
|
|
return |
|
} |
|
|
|
// NotificationHandler is a callback for handling NWC notifications |
|
type NotificationHandler func( |
|
notificationType string, notification map[string]any, |
|
) error |
|
|
|
// SubscribeNotifications subscribes to NWC notification events (kinds 23197/23196) |
|
// and handles them with the provided callback. It maintains a persistent connection |
|
// with auto-reconnection on disconnect. |
|
func (cl *Client) SubscribeNotifications( |
|
c context.Context, handler NotificationHandler, |
|
) (err error) { |
|
delay := time.Second |
|
for { |
|
if err = cl.subscribeNotificationsOnce(c, handler); err != nil { |
|
if errors.Is(err, context.Canceled) { |
|
return err |
|
} |
|
select { |
|
case <-time.After(delay): |
|
if delay < 30*time.Second { |
|
delay *= 2 |
|
} |
|
case <-c.Done(): |
|
return context.Canceled |
|
} |
|
continue |
|
} |
|
delay = time.Second |
|
} |
|
} |
|
|
|
// subscribeNotificationsOnce performs a single subscription attempt |
|
func (cl *Client) subscribeNotificationsOnce( |
|
c context.Context, handler NotificationHandler, |
|
) (err error) { |
|
// Connect to relay |
|
var rc *ws.Client |
|
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) { |
|
return fmt.Errorf("relay connection failed: %w", err) |
|
} |
|
defer rc.Close() |
|
|
|
// Subscribe to notification events filtered by "p" tag |
|
// Support both NIP-44 (kind 23197) and legacy NIP-04 (kind 23196) |
|
var sub *ws.Subscription |
|
if sub, err = rc.Subscribe( |
|
c, filter.NewS( |
|
&filter.F{ |
|
Kinds: kind.NewS(kind.New(23197), kind.New(23196)), |
|
Tags: tag.NewS( |
|
tag.NewFromAny("p", hex.Enc(cl.clientSecretKey.Pub())), |
|
), |
|
Since: ×tamp.T{V: time.Now().Unix()}, |
|
}, |
|
), |
|
); chk.E(err) { |
|
return fmt.Errorf("subscription failed: %w", err) |
|
} |
|
defer sub.Unsub() |
|
|
|
log.I.F( |
|
"subscribed to NWC notifications from wallet %s", |
|
hex.Enc(cl.walletPublicKey), |
|
) |
|
|
|
// Process notification events |
|
for { |
|
select { |
|
case <-c.Done(): |
|
return context.Canceled |
|
case ev := <-sub.Events: |
|
if ev == nil { |
|
// Channel closed, subscription ended |
|
return fmt.Errorf("subscription closed") |
|
} |
|
|
|
// Process the notification event |
|
if err := cl.processNotificationEvent(ev, handler); err != nil { |
|
log.E.F("error processing notification: %v", err) |
|
// Continue processing other notifications even if one fails |
|
} |
|
} |
|
} |
|
} |
|
|
|
// processNotificationEvent decrypts and processes a single notification event |
|
func (cl *Client) processNotificationEvent( |
|
ev *event.E, handler NotificationHandler, |
|
) (err error) { |
|
// Decrypt the notification content |
|
var decrypted string |
|
if decrypted, err = encryption.Decrypt( |
|
cl.conversationKey, string(ev.Content), |
|
); err != nil { |
|
return fmt.Errorf("failed to decrypt notification: %w", err) |
|
} |
|
|
|
// Parse the notification JSON |
|
var notification map[string]any |
|
if err = json.Unmarshal([]byte(decrypted), ¬ification); err != nil { |
|
return fmt.Errorf("failed to parse notification JSON: %w", err) |
|
} |
|
|
|
// Extract notification type |
|
notificationType, ok := notification["notification_type"].(string) |
|
if !ok { |
|
return fmt.Errorf("missing or invalid notification_type") |
|
} |
|
|
|
// Extract notification data |
|
notificationData, ok := notification["notification"].(map[string]any) |
|
if !ok { |
|
return fmt.Errorf("missing or invalid notification data") |
|
} |
|
|
|
// Route to type-specific handler |
|
return handler(notificationType, notificationData) |
|
}
|
|
|