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] + "…"
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
)
|
||||
|
||||
type NotifyInput struct {
|
||||
NetworkID string
|
||||
SenderHumanID string
|
||||
SenderEmailPrefix string
|
||||
|
||||
ParticleID string
|
||||
// One of "text", "media", "file", "quest", "paper". Containers (stream,
|
||||
// folder) are dropped by the caller before reaching the notifier.
|
||||
ParticleKind string
|
||||
|
||||
// Parent stream context — drives the title and the recipient set.
|
||||
StreamID string
|
||||
StreamName string
|
||||
StreamVisibleTo []string
|
||||
|
||||
// Body — already formatted by the caller (e.g. truncated text, "Sent a
|
||||
// voice message"). Title is derived inside the notifier.
|
||||
Body string
|
||||
}
|
||||
|
||||
// Notifier fans out one particle to Expo:
|
||||
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
||||
// 2. Send a batched Expo request for every recipient's tokens.
|
||||
// 3. Prune tokens Expo reports as DeviceNotRegistered.
|
||||
//
|
||||
// Online/offline presence is intentionally NOT consulted: a live WebSocket
|
||||
// is a poor proxy for "user is actively consuming this particle right now"
|
||||
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
|
||||
// false-negatives outweigh the duplicate-notification cost on a focused
|
||||
// device, which the OS handles via Focus modes and per-app settings.
|
||||
type Notifier struct {
|
||||
networkR network.Reader
|
||||
tokens Service
|
||||
expo *ExpoClient
|
||||
}
|
||||
|
||||
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
|
||||
return &Notifier{
|
||||
networkR: networkR,
|
||||
tokens: tokens,
|
||||
expo: expo,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
||||
if in.NetworkID == "" || in.ParticleID == "" {
|
||||
slog.Info("pushnotify: skip — missing ids",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list network members: %w", err)
|
||||
}
|
||||
|
||||
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
|
||||
recipientsBeforeSenderFilter := len(recipients)
|
||||
recipients = filterOut(recipients, in.SenderHumanID)
|
||||
if len(recipients) == 0 {
|
||||
slog.Info("pushnotify: skip — no recipients",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"senderHumanID", in.SenderHumanID,
|
||||
"members", len(members),
|
||||
"visibleTo", in.StreamVisibleTo,
|
||||
"resolved", recipientsBeforeSenderFilter,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
tokens, err := n.tokens.ListForHumans(ctx, recipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token lookup: %w", err)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
slog.Info("pushnotify: skip — no tokens for recipients",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"recipients", len(recipients),
|
||||
"recipientIDs", recipients,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
msgs := buildMessages(tokens, in)
|
||||
tickets, sendErr := n.expo.Send(ctx, msgs)
|
||||
slog.Info("pushnotify: dispatch",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"recipients", len(recipients),
|
||||
"tokens", len(tokens),
|
||||
"sent", len(tickets),
|
||||
)
|
||||
|
||||
n.cleanupDeadTokens(ctx, msgs, tickets)
|
||||
|
||||
if sendErr != nil {
|
||||
return fmt.Errorf("expo send: %w", sendErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
||||
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
||||
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
|
||||
for i, t := range tickets {
|
||||
if i >= len(msgs) {
|
||||
break
|
||||
}
|
||||
if t.Status != "error" || t.Details == nil {
|
||||
continue
|
||||
}
|
||||
code, _ := t.Details["error"].(string)
|
||||
if code != ExpoErrorDeviceNotRegistered {
|
||||
if t.Status == "error" {
|
||||
slog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
slog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
|
||||
} else {
|
||||
slog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
|
||||
title := in.SenderEmailPrefix
|
||||
if in.StreamName != "" {
|
||||
title = in.SenderEmailPrefix + " in " + in.StreamName
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"kind": "particle_created",
|
||||
"network_id": in.NetworkID,
|
||||
"stream_id": in.StreamID,
|
||||
"particle_id": in.ParticleID,
|
||||
"sender_human_id": in.SenderHumanID,
|
||||
"particle_kind": in.ParticleKind,
|
||||
}
|
||||
|
||||
msgs := make([]Message, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
msgs = append(msgs, Message{
|
||||
To: t.Token,
|
||||
Title: title,
|
||||
Body: in.Body,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func filterOut(ids []string, exclude string) []string {
|
||||
if exclude == "" {
|
||||
return ids
|
||||
}
|
||||
out := ids[:0:len(ids)]
|
||||
for _, id := range ids {
|
||||
if id != exclude {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type repository interface {
|
||||
upsert(ctx context.Context, t *PushToken) error
|
||||
deleteForHuman(ctx context.Context, humanID, token string) error
|
||||
deleteByToken(ctx context.Context, token string) error
|
||||
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO push_tokens (token, human_id, platform, app_version)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
ON CONFLICT (token) DO UPDATE SET
|
||||
human_id = EXCLUDED.human_id,
|
||||
platform = EXCLUDED.platform,
|
||||
app_version = EXCLUDED.app_version,
|
||||
last_seen_at = NOW()`,
|
||||
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
|
||||
humanID, token,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM push_tokens WHERE token = $1`,
|
||||
token,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
|
||||
if len(humanIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
|
||||
FROM push_tokens
|
||||
WHERE human_id = ANY($1)`,
|
||||
humanIDs,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []*PushToken
|
||||
for rows.Next() {
|
||||
var t PushToken
|
||||
var appVersion *string
|
||||
var platform string
|
||||
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Platform = Platform(platform)
|
||||
if appVersion != nil {
|
||||
t.AppVersion = *appVersion
|
||||
}
|
||||
tokens = append(tokens, &t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Service stores per-device Expo push tokens and exposes the operations
|
||||
// needed by both the HTTP handlers and the worker-side notifier.
|
||||
type Service interface {
|
||||
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
|
||||
Register(ctx context.Context, humanID string, in RegisterInput) error
|
||||
// Unregister is scoped to humanID so a user can't delete another user's
|
||||
// token. Returns ErrNotFound if the token isn't owned by humanID.
|
||||
Unregister(ctx context.Context, humanID, token string) error
|
||||
// ListForHumans returns an empty slice when nothing matches.
|
||||
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
|
||||
// DeleteByToken removes a token regardless of owner — used to prune after
|
||||
// Expo reports DeviceNotRegistered.
|
||||
DeleteByToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Token string
|
||||
Platform Platform
|
||||
AppVersion string
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool) Service {
|
||||
return &serviceImpl{repo: newRepository(pool)}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
|
||||
if !in.Platform.Valid() {
|
||||
return ErrInvalidPlatform
|
||||
}
|
||||
if !IsValidExpoToken(in.Token) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return s.repo.upsert(ctx, &PushToken{
|
||||
Token: in.Token,
|
||||
HumanID: humanID,
|
||||
Platform: in.Platform,
|
||||
AppVersion: in.AppVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
|
||||
if token == "" {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return s.repo.deleteForHuman(ctx, humanID, token)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
|
||||
return s.repo.listForHumans(ctx, humanIDs)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
|
||||
return s.repo.deleteByToken(ctx, token)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package pushnotify owns mobile push notification delivery: storage of per-device
|
||||
// Expo push tokens, and the worker-side orchestration of sending notifications
|
||||
// to offline recipients via the Expo Push API.
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
PlatformIOS Platform = "ios"
|
||||
PlatformAndroid Platform = "android"
|
||||
)
|
||||
|
||||
func (p Platform) Valid() bool {
|
||||
return p == PlatformIOS || p == PlatformAndroid
|
||||
}
|
||||
|
||||
type PushToken struct {
|
||||
Token string
|
||||
HumanID string
|
||||
Platform Platform
|
||||
AppVersion string
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidPlatform = errors.New("invalid platform")
|
||||
ErrInvalidToken = errors.New("invalid expo push token")
|
||||
ErrNotFound = errors.New("push token not found")
|
||||
)
|
||||
|
||||
// IsValidExpoToken matches the two prefix formats Expo currently uses.
|
||||
// We don't validate the inner contents — Expo's server will reject malformed
|
||||
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
|
||||
func IsValidExpoToken(token string) bool {
|
||||
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
|
||||
}
|
||||
Reference in New Issue
Block a user