subscription_handler.go raw

   1  package bridge
   2  
   3  import (
   4  	"context"
   5  	"fmt"
   6  	"time"
   7  
   8  	"next.orly.dev/pkg/lol/log"
   9  
  10  	"next.orly.dev/pkg/acl"
  11  	aclgrpc "next.orly.dev/pkg/acl/grpc"
  12  )
  13  
  14  // SubscriptionHandler manages the subscription flow:
  15  // user sends "subscribe" → create invoice → poll for payment → activate → confirm.
  16  type SubscriptionHandler struct {
  17  	store          SubscriptionStore
  18  	payments       *PaymentProcessor
  19  	sendDM         func(pubkeyHex string, content string) error
  20  	priceSats      int64
  21  	aclClient      *aclgrpc.Client
  22  	aliasPriceSats int64
  23  }
  24  
  25  // NewSubscriptionHandler creates a handler for subscription DM commands.
  26  // sendDM is a callback that sends a DM reply to the user.
  27  func NewSubscriptionHandler(
  28  	store SubscriptionStore,
  29  	payments *PaymentProcessor,
  30  	sendDM func(pubkeyHex string, content string) error,
  31  	priceSats int64,
  32  	aclClient *aclgrpc.Client,
  33  	aliasPriceSats int64,
  34  ) *SubscriptionHandler {
  35  	return &SubscriptionHandler{
  36  		store:          store,
  37  		payments:       payments,
  38  		sendDM:         sendDM,
  39  		priceSats:      priceSats,
  40  		aclClient:      aclClient,
  41  		aliasPriceSats: aliasPriceSats,
  42  	}
  43  }
  44  
  45  // HandleSubscribe processes a "subscribe" or "subscribe <alias>" command.
  46  // It creates an invoice, sends it to the user, waits for payment,
  47  // then activates the subscription and sends confirmation.
  48  func (sh *SubscriptionHandler) HandleSubscribe(ctx context.Context, pubkeyHex, alias string) {
  49  	// Determine price based on alias request
  50  	price := sh.priceSats
  51  	if alias != "" {
  52  		price = sh.aliasPriceSats
  53  		if price == 0 {
  54  			price = sh.priceSats * 2
  55  		}
  56  	}
  57  
  58  	// If alias requested, validate and check availability BEFORE creating invoice
  59  	if alias != "" {
  60  		if err := acl.ValidateAlias(alias); err != nil {
  61  			sh.sendReply(pubkeyHex, fmt.Sprintf("Invalid alias: %v", err))
  62  			return
  63  		}
  64  		if sh.aclClient != nil {
  65  			taken, err := sh.aclClient.IsAliasTaken(alias)
  66  			if err != nil {
  67  				log.E.F("alias check failed for %s: %v", alias, err)
  68  				sh.sendReply(pubkeyHex, "Failed to check alias availability. Please try again.")
  69  				return
  70  			}
  71  			if taken {
  72  				// Check if this pubkey already owns it (re-subscribe is ok)
  73  				existingPubkey, _ := sh.aclClient.GetPubkeyByAlias(alias)
  74  				if existingPubkey != pubkeyHex {
  75  					sh.sendReply(pubkeyHex, fmt.Sprintf("Alias %q is already taken. Try a different alias.", alias))
  76  					return
  77  				}
  78  			}
  79  		}
  80  	}
  81  
  82  	// Check for existing active subscription (via ACL client or file store)
  83  	if sh.aclClient != nil {
  84  		subscribed, err := sh.aclClient.IsSubscribedPaid(pubkeyHex)
  85  		if err == nil && subscribed {
  86  			sub, _ := sh.aclClient.GetSubscription(pubkeyHex)
  87  			if sub != nil {
  88  				remaining := time.Until(sub.ExpiresAt).Round(time.Hour)
  89  				sh.sendReply(pubkeyHex, fmt.Sprintf(
  90  					"You already have an active subscription (%v remaining). "+
  91  						"Send \"subscribe\" again after it expires to renew.",
  92  					remaining,
  93  				))
  94  				return
  95  			}
  96  		}
  97  	} else {
  98  		existing, err := sh.store.Get(pubkeyHex)
  99  		if err == nil && existing.IsActive() {
 100  			remaining := time.Until(existing.ExpiresAt).Round(time.Hour)
 101  			sh.sendReply(pubkeyHex, fmt.Sprintf(
 102  				"You already have an active subscription (%v remaining). "+
 103  					"Send \"subscribe\" again after it expires to renew.",
 104  				remaining,
 105  			))
 106  			return
 107  		}
 108  	}
 109  
 110  	// Create invoice
 111  	if sh.payments == nil {
 112  		log.E.F("subscription handler has no payment processor configured")
 113  		sh.sendReply(pubkeyHex, "Subscriptions are not available — payment processor not configured.")
 114  		return
 115  	}
 116  
 117  	invoice, err := sh.payments.CreateInvoice(ctx, price)
 118  	if err != nil {
 119  		log.E.F("failed to create subscription invoice for %s: %v", pubkeyHex, err)
 120  		sh.sendReply(pubkeyHex, "Failed to create invoice. Please try again later.")
 121  		return
 122  	}
 123  
 124  	// Send invoice to user
 125  	desc := fmt.Sprintf("Marmot Email Bridge subscription: %d sats/month", price)
 126  	if alias != "" {
 127  		desc = fmt.Sprintf("Marmot Email Bridge subscription with alias %q: %d sats/month", alias, price)
 128  	}
 129  	sh.sendReply(pubkeyHex, fmt.Sprintf(
 130  		"%s\n\nPay this Lightning invoice to activate:\n\n%s\n\n"+
 131  			"The invoice expires in 10 minutes. "+
 132  			"You'll receive a confirmation DM when payment is received.",
 133  		desc,
 134  		invoice.Bolt11,
 135  	))
 136  
 137  	// Wait for payment in background (10 minute timeout)
 138  	payCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
 139  	defer cancel()
 140  
 141  	status, err := sh.payments.WaitForPayment(payCtx, invoice.PaymentHash, 5*time.Second)
 142  	if err != nil {
 143  		log.D.F("subscription payment wait ended for %s: %v", pubkeyHex, err)
 144  		return
 145  	}
 146  
 147  	// Payment received — activate subscription
 148  	expiresAt := time.Now().Add(30 * 24 * time.Hour)
 149  
 150  	if sh.aclClient != nil {
 151  		// ACL-backed activation
 152  		if err := sh.aclClient.SubscribePubkey(pubkeyHex, expiresAt, status.PaymentHash, alias); err != nil {
 153  			log.E.F("failed to activate ACL subscription for %s: %v", pubkeyHex, err)
 154  			sh.sendReply(pubkeyHex, "Payment received but failed to activate subscription. Contact the relay operator.")
 155  			return
 156  		}
 157  		// Claim alias if requested
 158  		if alias != "" {
 159  			if err := sh.aclClient.ClaimAlias(alias, pubkeyHex); err != nil {
 160  				log.W.F("alias claim failed for %s → %s: %v", alias, pubkeyHex, err)
 161  				// Subscription is active, just alias failed
 162  				sh.sendReply(pubkeyHex, fmt.Sprintf(
 163  					"Payment received! Subscription active (expires %s).\n\n"+
 164  						"However, alias %q could not be claimed: %v\n"+
 165  						"You can still send email using your npub address.",
 166  					expiresAt.Format("2006-01-02"), alias, err,
 167  				))
 168  				return
 169  			}
 170  		}
 171  	} else {
 172  		// File-store fallback
 173  		sub := &Subscription{
 174  			PubkeyHex:   pubkeyHex,
 175  			ExpiresAt:   expiresAt,
 176  			CreatedAt:   time.Now(),
 177  			InvoiceHash: status.PaymentHash,
 178  		}
 179  		if err := sh.store.Save(sub); err != nil {
 180  			log.E.F("failed to save subscription for %s: %v", pubkeyHex, err)
 181  			sh.sendReply(pubkeyHex, "Payment received but failed to activate subscription. Contact the relay operator.")
 182  			return
 183  		}
 184  	}
 185  
 186  	log.I.F("subscription activated for %s (alias=%q, expires %s)", pubkeyHex, alias, expiresAt.Format(time.RFC3339))
 187  
 188  	confirmMsg := fmt.Sprintf(
 189  		"Payment received! Your subscription is now active.\n\n"+
 190  			"Expires: %s\n",
 191  		expiresAt.Format("2006-01-02"),
 192  	)
 193  	if alias != "" {
 194  		confirmMsg += fmt.Sprintf("Alias: %s\n", alias)
 195  	}
 196  	confirmMsg += "\nYou can now send emails by DMing this bridge with email headers:\n\n" +
 197  		"To: recipient@example.com\n" +
 198  		"Subject: Your subject\n\n" +
 199  		"Your message here."
 200  
 201  	sh.sendReply(pubkeyHex, confirmMsg)
 202  }
 203  
 204  // HandleStatus replies with the user's subscription info.
 205  func (sh *SubscriptionHandler) HandleStatus(pubkeyHex string) {
 206  	if sh.aclClient != nil {
 207  		sub, err := sh.aclClient.GetSubscription(pubkeyHex)
 208  		if err != nil {
 209  			sh.sendReply(pubkeyHex, "No active subscription found.")
 210  			return
 211  		}
 212  		msg := fmt.Sprintf("Subscription status:\n\nExpires: %s\n", sub.ExpiresAt.Format("2006-01-02"))
 213  		if sub.HasAlias {
 214  			msg += fmt.Sprintf("Alias: %s\n", sub.Alias)
 215  		}
 216  		remaining := time.Until(sub.ExpiresAt).Round(time.Hour)
 217  		if remaining > 0 {
 218  			msg += fmt.Sprintf("Time remaining: %v\n", remaining)
 219  		} else {
 220  			msg += "Status: EXPIRED\n"
 221  		}
 222  		sh.sendReply(pubkeyHex, msg)
 223  		return
 224  	}
 225  
 226  	// File-store fallback
 227  	sub, err := sh.store.Get(pubkeyHex)
 228  	if err != nil {
 229  		sh.sendReply(pubkeyHex, "No active subscription found.")
 230  		return
 231  	}
 232  	remaining := time.Until(sub.ExpiresAt).Round(time.Hour)
 233  	status := "active"
 234  	if remaining <= 0 {
 235  		status = "EXPIRED"
 236  	}
 237  	sh.sendReply(pubkeyHex, fmt.Sprintf(
 238  		"Subscription status: %s\nExpires: %s\nTime remaining: %v",
 239  		status, sub.ExpiresAt.Format("2006-01-02"), remaining,
 240  	))
 241  }
 242  
 243  func (sh *SubscriptionHandler) sendReply(pubkeyHex, content string) {
 244  	if err := sh.sendDM(pubkeyHex, content); err != nil {
 245  		log.E.F("failed to send DM reply to %s: %v", pubkeyHex, err)
 246  	}
 247  }
 248  
 249  // IsSubscribed checks whether a user has an active subscription.
 250  func (sh *SubscriptionHandler) IsSubscribed(pubkeyHex string) bool {
 251  	if sh.aclClient != nil {
 252  		subscribed, err := sh.aclClient.IsSubscribedPaid(pubkeyHex)
 253  		if err != nil {
 254  			return false
 255  		}
 256  		return subscribed
 257  	}
 258  	sub, err := sh.store.Get(pubkeyHex)
 259  	if err != nil {
 260  		return false
 261  	}
 262  	return sub.IsActive()
 263  }
 264