d262f734f0
* 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
139 lines
3.8 KiB
Go
139 lines
3.8 KiB
Go
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] + "…"
|
|
}
|