Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
|
||||
// expoMaxBatchSize is the documented per-request cap on push messages.
|
||||
expoMaxBatchSize = 100
|
||||
|
||||
// Ticket error codes returned by Expo Push API. The only one we act on is
|
||||
// DeviceNotRegistered — others are logged but not retried (per product call).
|
||||
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
|
||||
)
|
||||
|
||||
// Sound defaults to "default" when empty (set in Send).
|
||||
type Message struct {
|
||||
To string `json:"to"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
}
|
||||
|
||||
// Status is "ok" or "error". On error, Details["error"] carries the code
|
||||
// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
|
||||
type Ticket struct {
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget,
|
||||
// with DeviceNotRegistered handled out-of-band by the notifier.
|
||||
type ExpoClient struct {
|
||||
http *http.Client
|
||||
accessToken string
|
||||
}
|
||||
|
||||
func NewExpoClient(accessToken string) *ExpoClient {
|
||||
return &ExpoClient{
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
accessToken: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
type expoSendResponse struct {
|
||||
Data []Ticket `json:"data"`
|
||||
Errors []map[string]any `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Send batches msgs (cap expoMaxBatchSize) and preserves input order:
|
||||
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
|
||||
// remaining batches; tickets already collected are returned with the error.
|
||||
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
|
||||
if len(msgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for i := range msgs {
|
||||
if msgs[i].Sound == "" {
|
||||
msgs[i].Sound = "default"
|
||||
}
|
||||
}
|
||||
|
||||
tickets := make([]Ticket, 0, len(msgs))
|
||||
for start := 0; start < len(msgs); start += expoMaxBatchSize {
|
||||
end := start + expoMaxBatchSize
|
||||
if end > len(msgs) {
|
||||
end = len(msgs)
|
||||
}
|
||||
|
||||
batch := msgs[start:end]
|
||||
batchTickets, err := c.sendBatch(ctx, batch)
|
||||
tickets = append(tickets, batchTickets...)
|
||||
if err != nil {
|
||||
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
|
||||
}
|
||||
}
|
||||
return tickets, nil
|
||||
}
|
||||
|
||||
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
|
||||
body, err := json.Marshal(batch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal batch: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
|
||||
}
|
||||
|
||||
var parsed expoSendResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if len(parsed.Data) != len(batch) {
|
||||
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
|
||||
}
|
||||
return parsed.Data, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user