implement backend components for push notifications
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
@@ -119,13 +118,6 @@ func runNotificationCycle(
|
||||
}
|
||||
|
||||
for _, net := range networks {
|
||||
// Build set of all member humanIds for this network (members + admin)
|
||||
networkMembers := make(map[string]bool, len(net.MemberHumanIds)+1)
|
||||
for _, id := range net.MemberHumanIds {
|
||||
networkMembers[id] = true
|
||||
}
|
||||
networkMembers[net.AdminHumanId] = true
|
||||
|
||||
// Query Firestore for open streams in this network
|
||||
streams, err := getOpenStreams(ctx, fsClient, net.ID)
|
||||
if err != nil {
|
||||
@@ -133,6 +125,8 @@ func runNotificationCycle(
|
||||
continue
|
||||
}
|
||||
|
||||
// net.MemberHumanIds already contains the admin (Create() adds them and
|
||||
// RemoveMemberFromNetwork won't drop them) — no need to union separately.
|
||||
for _, stream := range streams {
|
||||
if stream.LastChildCreatedAt == nil {
|
||||
continue
|
||||
@@ -146,10 +140,8 @@ func runNotificationCycle(
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve members from visible_to
|
||||
members := resolveMembers(stream.VisibleTo, networkMembers)
|
||||
|
||||
for humanId := range members {
|
||||
// Resolve members from visible_to using the shared helper
|
||||
for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
|
||||
marker, hasMarker := stream.PlaybackMarkers[humanId]
|
||||
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
|
||||
continue // up to date
|
||||
@@ -239,27 +231,6 @@ func getOpenStreams(ctx context.Context, client *firestore.Client, networkId str
|
||||
return streams, nil
|
||||
}
|
||||
|
||||
// resolveMembers expands visible_to entries into a set of humanIds.
|
||||
// "human:{id}" adds that id directly. "network:{id}" expands to all network members.
|
||||
func resolveMembers(visibleTo []string, networkMembers map[string]bool) map[string]bool {
|
||||
members := map[string]bool{}
|
||||
for _, entry := range visibleTo {
|
||||
if strings.HasPrefix(entry, "human:") {
|
||||
humanId := strings.TrimPrefix(entry, "human:")
|
||||
// human can be in visible_to, but no longer a member of the network
|
||||
if _, ok := networkMembers[humanId]; ok {
|
||||
members[humanId] = true
|
||||
}
|
||||
} else if strings.HasPrefix(entry, "network:") {
|
||||
// Expand to all network members
|
||||
for id := range networkMembers {
|
||||
members[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
|
||||
streamsWord := "stream"
|
||||
if streamCount != 1 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/flowy-live/llink/internal/depot"
|
||||
"github.com/flowy-live/llink/internal/handler"
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
"github.com/flowy-live/llink/internal/human/pushnotify"
|
||||
"github.com/flowy-live/llink/internal/livekit"
|
||||
"github.com/flowy-live/llink/internal/livestore"
|
||||
"github.com/flowy-live/llink/internal/middleware"
|
||||
@@ -106,9 +107,10 @@ func main() {
|
||||
BucketName: gcsBucket,
|
||||
})
|
||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||
livekitClient := livekit.NewClient()
|
||||
|
||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient)
|
||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
|
||||
|
||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
||||
@@ -143,6 +145,10 @@ func main() {
|
||||
// Settings
|
||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||
|
||||
// Push notification tokens (per-device)
|
||||
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
|
||||
|
||||
// Networks
|
||||
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||
|
||||
@@ -6,15 +6,22 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
||||
"github.com/flowy-live/llink/internal/billing"
|
||||
"github.com/flowy-live/llink/internal/db"
|
||||
"github.com/flowy-live/llink/internal/depot"
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
"github.com/flowy-live/llink/internal/human/pushnotify"
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/particle"
|
||||
"github.com/flowy-live/llink/internal/speech"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
@@ -61,6 +68,24 @@ func main() {
|
||||
|
||||
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())
|
||||
networkReader := network.NewReader(db.Pool())
|
||||
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||
// EXPO_ACCESS_TOKEN is required: with Enhanced Security enabled on the Expo
|
||||
// project, sends without it fail; without it, anyone holding one of our
|
||||
// push tokens could spam our users via the public Expo endpoint.
|
||||
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
|
||||
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, pusherClient, expoClient)
|
||||
|
||||
client := createClient(ctx)
|
||||
defer client.Close()
|
||||
|
||||
@@ -103,12 +128,13 @@ func main() {
|
||||
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
|
||||
|
||||
// --- Perform side effects ---
|
||||
// All of them do not stop us from marking the particle as processed
|
||||
|
||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||
// All of them do not stop us from marking the particle as processed.
|
||||
parentDoc := loadParentParticle(ctx, change.Doc)
|
||||
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
|
||||
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||
particle.Transcode(ctx, depotSvc, change.Doc)
|
||||
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
|
||||
|
||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
||||
@@ -117,35 +143,38 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) {
|
||||
// transcribeMediaParticle transcribes a media particle, writes the structured
|
||||
// transcript to Firestore, and returns the raw transcript text. Returns "" for
|
||||
// non-media particles or on any error (errors are logged internally).
|
||||
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
|
||||
var mediaParticle particle.FirestoreMediaParticle
|
||||
err := doc.DataTo(&mediaParticle)
|
||||
if err != nil {
|
||||
slog.Error("unable to marshal particle data", "error", err)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||||
if err != nil {
|
||||
slog.Error("invalid particle type", "error", err)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
if particleType != particle.TypeMedia {
|
||||
slog.Info("received a particle of type", "particle type", particleType)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||||
if err != nil {
|
||||
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
result, err := speechSvc.Transcribe(ctx, downloadURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
transcript := toFirestoreTranscript(result)
|
||||
@@ -157,10 +186,11 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
|
||||
}, firestore.MergeAll)
|
||||
if err != nil {
|
||||
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
||||
return transcript.Transcript
|
||||
}
|
||||
|
||||
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||||
@@ -233,31 +263,36 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
|
||||
}
|
||||
}
|
||||
|
||||
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
||||
// playback markers (which also store child created_at values).
|
||||
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) {
|
||||
// loadParentParticle fetches the immediate parent particle doc for `doc`.
|
||||
// Returns nil (and logs) if the path doesn't have a parent or the read fails.
|
||||
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
|
||||
parentChildrenCollectionRef := doc.Ref.Parent
|
||||
if parentChildrenCollectionRef == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
parentParticleDocRef := parentChildrenCollectionRef.Parent
|
||||
if parentParticleDocRef == nil {
|
||||
slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to get parent particle", "error", err)
|
||||
slog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
|
||||
return nil
|
||||
}
|
||||
return parentParticleDoc
|
||||
}
|
||||
|
||||
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
||||
// playback markers (which also store child created_at values).
|
||||
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
|
||||
if parent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID)
|
||||
|
||||
var streamParticle particle.FirestoreStreamParticle
|
||||
if err := parentParticleDoc.DataTo(&streamParticle); err != nil {
|
||||
if err := parent.DataTo(&streamParticle); err != nil {
|
||||
slog.Error("failed to parse stream particle", "error", err)
|
||||
return
|
||||
}
|
||||
@@ -279,14 +314,150 @@ func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.Document
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("going to update the last_child_created_at for parent particle")
|
||||
_, err = parentParticleDocRef.Update(ctx, []firestore.Update{
|
||||
_, err = parent.Ref.Update(ctx, []firestore.Update{
|
||||
{
|
||||
Path: "last_child_created_at",
|
||||
Value: childCreatedAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("unable to update parent particle `last_child_created_at`")
|
||||
slog.Error("unable to update parent particle `last_child_created_at`", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyForParticle dispatches a push notification for a newly-created particle.
|
||||
// Skips containers (streams/folders) and particles whose parent isn't a stream
|
||||
// (notifications are only sent for stream messages today). The transcript arg
|
||||
// is used as the preview body for media particles when available.
|
||||
func notifyForParticle(
|
||||
ctx context.Context,
|
||||
notifier *pushnotify.Notifier,
|
||||
humanSvc human.Service,
|
||||
doc *firestore.DocumentSnapshot,
|
||||
parent *firestore.DocumentSnapshot,
|
||||
transcript string,
|
||||
) {
|
||||
if parent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
typeStr, _ := doc.DataAt("type")
|
||||
typeName, _ := typeStr.(string)
|
||||
pType, err := particle.ParseParticleType(typeName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if pType == particle.TypeStream || pType == particle.TypeFolder {
|
||||
return
|
||||
}
|
||||
|
||||
var parentStream particle.FirestoreStreamParticle
|
||||
if err := parent.DataTo(&parentStream); err != nil {
|
||||
slog.Error("notify: failed to parse parent stream", "error", err)
|
||||
return
|
||||
}
|
||||
parentType, err := particle.ParseParticleType(parentStream.Type)
|
||||
if err != nil || parentType != particle.TypeStream {
|
||||
return
|
||||
}
|
||||
|
||||
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
|
||||
if err != nil {
|
||||
slog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||
return
|
||||
}
|
||||
|
||||
senderHumanID := parentStream.CreatedByHumanId
|
||||
if v, err := doc.DataAt("created_by_human_id"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
senderHumanID = s
|
||||
}
|
||||
}
|
||||
|
||||
streamName := ""
|
||||
if v, err := parent.DataAt("properties.name"); err == nil {
|
||||
if s, ok := v.(string); ok {
|
||||
streamName = s
|
||||
}
|
||||
}
|
||||
|
||||
senderEmailPrefix := ""
|
||||
if senderHumanID != "" {
|
||||
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
|
||||
senderEmailPrefix = sender.EmailPrefix
|
||||
} else {
|
||||
slog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
|
||||
NetworkID: networkID,
|
||||
SenderHumanID: senderHumanID,
|
||||
SenderEmailPrefix: senderEmailPrefix,
|
||||
ParticleID: doc.Ref.ID,
|
||||
ParticleKind: string(pType),
|
||||
StreamID: parent.Ref.ID,
|
||||
StreamName: streamName,
|
||||
StreamVisibleTo: parentStream.VisibleTo,
|
||||
Body: previewForParticle(pType, doc, transcript),
|
||||
}); err != nil {
|
||||
slog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
|
||||
}
|
||||
}
|
||||
|
||||
// previewForParticle builds the visible notification body. Kept short — push
|
||||
// previews truncate aggressively on lockscreens. For media, prefers the
|
||||
// transcript text (already computed by transcribeMediaParticle in the same
|
||||
// processing step) and falls back to the generic "Sent a ..." line if speech
|
||||
// recognition produced nothing.
|
||||
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
|
||||
switch pType {
|
||||
case particle.TypeText:
|
||||
if v, err := doc.DataAt("properties.content"); err == nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return truncatePreview(s, 140)
|
||||
}
|
||||
}
|
||||
return "Sent a message"
|
||||
case particle.TypeMedia:
|
||||
if t := strings.TrimSpace(transcript); t != "" {
|
||||
return truncatePreview(t, 140)
|
||||
}
|
||||
mime := ""
|
||||
if v, err := doc.DataAt("properties.mime_type"); err == nil {
|
||||
if s, ok := v.(string); ok {
|
||||
mime = s
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(mime, "video/") {
|
||||
return "Sent a video"
|
||||
}
|
||||
return "Sent a voice message"
|
||||
case particle.TypeFile:
|
||||
return "Sent a file"
|
||||
case particle.TypeQuest:
|
||||
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return "Quest: " + truncatePreview(s, 120)
|
||||
}
|
||||
}
|
||||
return "Added a quest"
|
||||
case particle.TypePaper:
|
||||
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return "Paper: " + truncatePreview(s, 120)
|
||||
}
|
||||
}
|
||||
return "Added a paper"
|
||||
default:
|
||||
return "New activity"
|
||||
}
|
||||
}
|
||||
|
||||
func truncatePreview(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user