266 lines
6.3 KiB
Go
266 lines
6.3 KiB
Go
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 []byte
|
|
if content, err = encryption.Encrypt(req, cl.conversationKey); chk.E(err) {
|
|
return
|
|
}
|
|
|
|
ev := &event.E{
|
|
Content: 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 []byte
|
|
if raw, err = encryption.Decrypt(
|
|
e.Content, cl.conversationKey,
|
|
); chk.E(err) {
|
|
return fmt.Errorf(
|
|
"decryption failed (invalid conversation key): %w", err,
|
|
)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err = json.Unmarshal(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 []byte
|
|
if decrypted, err = encryption.Decrypt(
|
|
ev.Content, cl.conversationKey,
|
|
); err != nil {
|
|
return fmt.Errorf("failed to decrypt notification: %w", err)
|
|
}
|
|
|
|
// Parse the notification JSON
|
|
var notification map[string]any
|
|
if err = json.Unmarshal(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)
|
|
}
|