send push notifications regardless of online status
This commit is contained in:
@@ -9,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
|
||||||
"github.com/flowy-live/llink/internal/billing"
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
@@ -19,9 +18,7 @@ import (
|
|||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/speech"
|
"github.com/flowy-live/llink/internal/speech"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
@@ -64,22 +61,13 @@ func main() {
|
|||||||
|
|
||||||
speechSvc := speech.NewSpeechService(ctx)
|
speechSvc := speech.NewSpeechService(ctx)
|
||||||
|
|
||||||
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
|
|
||||||
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to pusher", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pusherConn.Close()
|
|
||||||
pusherClient := pbpusher.NewPusherServiceClient(pusherConn)
|
|
||||||
|
|
||||||
humanSvc := human.NewService(db.Pool())
|
humanSvc := human.NewService(db.Pool())
|
||||||
networkReader := network.NewReader(db.Pool())
|
networkReader := network.NewReader(db.Pool())
|
||||||
pushTokenSvc := pushnotify.NewService(db.Pool())
|
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||||
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
|
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
|
||||||
// project (otherwise anyone holding one of our push tokens could spam users).
|
// project (otherwise anyone holding one of our push tokens could spam users).
|
||||||
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
|
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
|
||||||
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, pusherClient, expoClient)
|
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
|
||||||
|
|
||||||
client := createClient(ctx)
|
client := createClient(ctx)
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
@@ -327,6 +315,7 @@ func notifyForParticle(
|
|||||||
transcript string,
|
transcript string,
|
||||||
) {
|
) {
|
||||||
if parent == nil {
|
if parent == nil {
|
||||||
|
slog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,9 +323,13 @@ func notifyForParticle(
|
|||||||
typeName, _ := typeStr.(string)
|
typeName, _ := typeStr.(string)
|
||||||
pType, err := particle.ParseParticleType(typeName)
|
pType, err := particle.ParseParticleType(typeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Info("notify: skip — unparseable particle type",
|
||||||
|
"particleID", doc.Ref.ID, "type", typeName, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if pType == particle.TypeStream || pType == particle.TypeFolder {
|
if pType == particle.TypeStream || pType == particle.TypeFolder {
|
||||||
|
slog.Info("notify: skip — container particle",
|
||||||
|
"particleID", doc.Ref.ID, "type", pType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +340,8 @@ func notifyForParticle(
|
|||||||
}
|
}
|
||||||
parentType, err := particle.ParseParticleType(parentStream.Type)
|
parentType, err := particle.ParseParticleType(parentStream.Type)
|
||||||
if err != nil || parentType != particle.TypeStream {
|
if err != nil || parentType != particle.TypeStream {
|
||||||
|
slog.Info("notify: skip — parent isn't a stream",
|
||||||
|
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PusherClient is a narrow subset of the pusher gRPC service so tests can
|
|
||||||
// supply a fake without standing up a real server.
|
|
||||||
type PusherClient interface {
|
|
||||||
IsOnline(ctx context.Context, in *pbpusher.IsOnlineRequest, opts ...grpc.CallOption) (*pbpusher.IsOnlineResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotifyInput struct {
|
type NotifyInput struct {
|
||||||
NetworkID string
|
NetworkID string
|
||||||
SenderHumanID string
|
SenderHumanID string
|
||||||
@@ -39,27 +31,34 @@ type NotifyInput struct {
|
|||||||
|
|
||||||
// Notifier fans out one particle to Expo:
|
// Notifier fans out one particle to Expo:
|
||||||
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
||||||
// 2. Drop anyone currently connected via WebSocket.
|
// 2. Send a batched Expo request for every recipient's tokens.
|
||||||
// 3. Send a batched Expo request for the remainder's tokens.
|
// 3. Prune tokens Expo reports as DeviceNotRegistered.
|
||||||
// 4. 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 {
|
type Notifier struct {
|
||||||
networkR network.Reader
|
networkR network.Reader
|
||||||
tokens Service
|
tokens Service
|
||||||
pusher PusherClient
|
|
||||||
expo *ExpoClient
|
expo *ExpoClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotifier(networkR network.Reader, tokens Service, pusher PusherClient, expo *ExpoClient) *Notifier {
|
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
|
||||||
return &Notifier{
|
return &Notifier{
|
||||||
networkR: networkR,
|
networkR: networkR,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
pusher: pusher,
|
|
||||||
expo: expo,
|
expo: expo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
||||||
if in.NetworkID == "" || in.ParticleID == "" {
|
if in.NetworkID == "" || in.ParticleID == "" {
|
||||||
|
slog.Info("pushnotify: skip — missing ids",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,31 +68,31 @@ func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
|
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
|
||||||
|
recipientsBeforeSenderFilter := len(recipients)
|
||||||
recipients = filterOut(recipients, in.SenderHumanID)
|
recipients = filterOut(recipients, in.SenderHumanID)
|
||||||
if len(recipients) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
online, err := n.queryOnline(ctx, recipients)
|
tokens, err := n.tokens.ListForHumans(ctx, recipients)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("presence lookup: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
offline := make([]string, 0, len(recipients))
|
|
||||||
for _, id := range recipients {
|
|
||||||
if !online[id] {
|
|
||||||
offline = append(offline, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(offline) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := n.tokens.ListForHumans(ctx, offline)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("token lookup: %w", err)
|
return fmt.Errorf("token lookup: %w", err)
|
||||||
}
|
}
|
||||||
if len(tokens) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +102,6 @@ func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) er
|
|||||||
"networkID", in.NetworkID,
|
"networkID", in.NetworkID,
|
||||||
"particleID", in.ParticleID,
|
"particleID", in.ParticleID,
|
||||||
"recipients", len(recipients),
|
"recipients", len(recipients),
|
||||||
"offline", len(offline),
|
|
||||||
"tokens", len(tokens),
|
"tokens", len(tokens),
|
||||||
"sent", len(tickets),
|
"sent", len(tickets),
|
||||||
)
|
)
|
||||||
@@ -116,14 +114,6 @@ func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) queryOnline(ctx context.Context, humanIDs []string) (map[string]bool, error) {
|
|
||||||
resp, err := n.pusher.IsOnline(ctx, &pbpusher.IsOnlineRequest{HumanIds: humanIDs})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resp.Online, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
||||||
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
||||||
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
|
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
|
||||||
|
|||||||
Reference in New Issue
Block a user