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
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
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
|
|
}
|