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