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] + "…" }