package main import ( "context" "fmt" "log" "os" "strings" "time" "github.com/flowy-live/llink/internal/utils/flog" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "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" "cloud.google.com/go/firestore" "cloud.google.com/go/storage" ) func createClient(ctx context.Context) *firestore.Client { projectId := utils.MustGetEnv("GCP_PROJECT") client, err := firestore.NewClient(ctx, projectId) if err != nil { log.Fatalf("Failed to create client: %v", err) } return client } // Listens for new particles and runs per-particle side effects: transcripts, // transcode, parent stream's last_child_created_at, freemium usage, and push // notifications for offline recipients. func main() { ctx := context.Background() db.Init() defer db.Cleanup() processingRepo := particle.NewProcessingRepository(db.Pool()) billingSvc := billing.NewServiceForWorker(db.Pool()) storageClient, err := storage.NewClient(ctx) if err != nil { flog.Error("failed to create GCS client", "error", err) os.Exit(1) } defer storageClient.Close() gcsBucket := utils.MustGetEnv("GCS_BUCKET") depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), BucketName: gcsBucket, }) speechSvc := speech.NewSpeechService(ctx) humanSvc := human.NewService(db.Pool()) networkReader := network.NewReader(db.Pool()) pushTokenSvc := pushnotify.NewService(db.Pool()) // EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo // project (otherwise anyone holding one of our push tokens could spam users). expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN")) notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient) client := createClient(ctx) defer client.Close() cutoff := time.Now().Add(-5 * time.Minute) it := client.CollectionGroup("children"). Where("created_at", ">", cutoff). Snapshots(ctx) for { snap, err := it.Next() if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled { panic(fmt.Errorf("error: %w", err)) } if err != nil { flog.Error("error in processing snapshot", "error", err) continue } if snap == nil { continue } for _, change := range snap.Changes { if change.Kind != firestore.DocumentAdded { continue } particleID := change.Doc.Ref.ID processed, err := processingRepo.IsProcessed(ctx, particleID) if err != nil { flog.Error("failed to check processing status", "particleID", particleID, "error", err) continue } if processed { flog.Debug("skipping already processed particle", "particleID", particleID) continue } flog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data()) // Side effects below are best-effort — failures don't prevent // 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 { flog.Error("failed to mark particle as processed", "particleID", particleID, "error", err) } } } } // Writes the structured transcript to Firestore and returns the raw text; // returns "" for non-media particles or on any error (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 { flog.Error("unable to marshal particle data", "error", err) return "" } particleType, err := particle.ParseParticleType(mediaParticle.Type) if err != nil { flog.Error("invalid particle type", "error", err) return "" } if particleType != particle.TypeMedia { flog.Info("received a particle of type", "particle type", particleType) return "" } downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId) if err != nil { flog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId) return "" } result, err := speechSvc.Transcribe(ctx, downloadURL) if err != nil { flog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID) return "" } transcript := toFirestoreTranscript(result) _, err = doc.Ref.Set(ctx, map[string]interface{}{ "properties": map[string]interface{}{ "transcript": transcript, }, }, firestore.MergeAll) if err != nil { flog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID) return "" } flog.Info("transcribed media particle", "particleID", doc.Ref.ID) return transcript.Transcript } func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript { words := make([]particle.FirestoreTranscriptWord, len(result.Words)) for i, w := range result.Words { words[i] = particle.FirestoreTranscriptWord{ Word: w.Word, Start: w.Start, End: w.End, } } paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs)) for i, p := range result.Paragraphs { sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences)) for j, s := range p.Sentences { sentences[j] = particle.FirestoreTranscriptSentence{ Text: s.Text, Start: s.Start, End: s.End, } } paragraphs[i] = particle.FirestoreTranscriptParagraph{ Sentences: sentences, Start: p.Start, End: p.End, } } return particle.FirestoreTranscript{ Transcript: result.Transcript, Words: words, Paragraphs: paragraphs, } } // Bumps the network's daily message counter for non-container particles. // The surrounding processed_particles guard keeps this idempotent across // crashes/restarts. func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) { rawType, err := doc.DataAt("type") if err != nil { flog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID) return } typeStr, ok := rawType.(string) if !ok { flog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType) return } particleType, err := particle.ParseParticleType(typeStr) if err != nil { flog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID) return } // Containers don't count toward the daily message cap. if particleType == particle.TypeStream || particleType == particle.TypeFolder { return } networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path) if err != nil { flog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path) return } if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil { flog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID) } } // Returns nil (and logs) if the path has no parent or the read fails. func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot { parentChildrenCollectionRef := doc.Ref.Parent if parentChildrenCollectionRef == nil { return nil } parentParticleDocRef := parentChildrenCollectionRef.Parent if parentParticleDocRef == nil { flog.Error("particle has no parent document", "particleID", doc.Ref.ID) return nil } parentParticleDoc, err := parentParticleDocRef.Get(ctx) if err != nil { flog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID) return nil } return parentParticleDoc } // Sets last_child_created_at to the child's created_at 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 } var streamParticle particle.FirestoreStreamParticle if err := parent.DataTo(&streamParticle); err != nil { flog.Error("failed to parse stream particle", "error", err) return } particleType, err := particle.ParseParticleType(streamParticle.Type) if err != nil { flog.Error("invalid particle type", "error", err) return } if particleType != particle.TypeStream { return } childCreatedAt, err := doc.DataAt("created_at") if err != nil { flog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID) return } _, err = parent.Ref.Update(ctx, []firestore.Update{ { Path: "last_child_created_at", Value: childCreatedAt, }, }) if err != nil { flog.Error("unable to update parent particle `last_child_created_at`", "error", err) } } // Skips containers and particles whose parent isn't a stream — notifications // are scoped to stream messages today. The transcript arg becomes 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 { flog.Info("notify: skip — no parent", "particleID", doc.Ref.ID) return } typeStr, _ := doc.DataAt("type") typeName, _ := typeStr.(string) pType, err := particle.ParseParticleType(typeName) if err != nil { flog.Info("notify: skip — unparseable particle type", "particleID", doc.Ref.ID, "type", typeName, "error", err) return } if pType == particle.TypeStream || pType == particle.TypeFolder { flog.Info("notify: skip — container particle", "particleID", doc.Ref.ID, "type", pType) return } var parentStream particle.FirestoreStreamParticle if err := parent.DataTo(&parentStream); err != nil { flog.Error("notify: failed to parse parent stream", "error", err) return } parentType, err := particle.ParseParticleType(parentStream.Type) if err != nil || parentType != particle.TypeStream { flog.Info("notify: skip — parent isn't a stream", "particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err) return } networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path) if err != nil { flog.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 { flog.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 { flog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID) } } // Builds the notification body. Kept short — lockscreens truncate aggressively. // Media prefers transcript text and falls back to a generic "Sent a …" line. 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] + "…" }