From 696f932baac2eadad5937a5d3f8a4b8b8eca91ec Mon Sep 17 00:00:00 2001 From: Andre Medeiros Date: Wed, 19 Oct 2022 09:37:41 -0400 Subject: [PATCH] Live activities --- internal/api/api.go | 25 +- internal/api/live_activities.go | 36 + internal/cmd/scheduler.go | 57 + internal/cmd/worker.go | 1 + internal/domain/live_activity.go | 38 + internal/reddit/client.go | 22 + internal/reddit/testdata/thread.json | 1734 +++++++++++++++++ internal/reddit/types.go | 23 + internal/reddit/types_test.go | 21 + internal/repository/postgres_live_activity.go | 123 ++ internal/worker/live_activities.go | 312 +++ internal/worker/notifications.go | 2 +- 12 files changed, 2383 insertions(+), 11 deletions(-) create mode 100644 internal/api/live_activities.go create mode 100644 internal/domain/live_activity.go create mode 100644 internal/reddit/testdata/thread.json create mode 100644 internal/repository/postgres_live_activity.go create mode 100644 internal/worker/live_activities.go diff --git a/internal/api/api.go b/internal/api/api.go index 0465365..c7379c3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -29,11 +29,12 @@ type api struct { apns *token.Token httpClient *http.Client - accountRepo domain.AccountRepository - deviceRepo domain.DeviceRepository - subredditRepo domain.SubredditRepository - watcherRepo domain.WatcherRepository - userRepo domain.UserRepository + accountRepo domain.AccountRepository + deviceRepo domain.DeviceRepository + subredditRepo domain.SubredditRepository + watcherRepo domain.WatcherRepository + userRepo domain.UserRepository + liveActivityRepo domain.LiveActivityRepository } func NewAPI(ctx context.Context, logger *zap.Logger, statsd *statsd.Client, redis *redis.Client, pool *pgxpool.Pool) *api { @@ -64,6 +65,7 @@ func NewAPI(ctx context.Context, logger *zap.Logger, statsd *statsd.Client, redi subredditRepo := repository.NewPostgresSubreddit(pool) watcherRepo := repository.NewPostgresWatcher(pool) userRepo := repository.NewPostgresUser(pool) + liveActivityRepo := repository.NewPostgresLiveActivity(pool) client := &http.Client{} @@ -74,11 +76,12 @@ func NewAPI(ctx context.Context, logger *zap.Logger, statsd *statsd.Client, redi apns: apns, httpClient: client, - accountRepo: accountRepo, - deviceRepo: deviceRepo, - subredditRepo: subredditRepo, - watcherRepo: watcherRepo, - userRepo: userRepo, + accountRepo: accountRepo, + deviceRepo: deviceRepo, + subredditRepo: subredditRepo, + watcherRepo: watcherRepo, + userRepo: userRepo, + liveActivityRepo: liveActivityRepo, } } @@ -115,6 +118,8 @@ func (a *api) Routes() *mux.Router { r.HandleFunc("/v1/device/{apns}/account/{redditID}/watcher/{watcherID}", a.editWatcherHandler).Methods("PATCH") r.HandleFunc("/v1/device/{apns}/account/{redditID}/watchers", a.listWatchersHandler).Methods("GET") + r.HandleFunc("/v1/live_activities", a.createLiveActivityHandler).Methods("POST") + r.HandleFunc("/v1/receipt", a.checkReceiptHandler).Methods("POST") r.HandleFunc("/v1/receipt/{apns}", a.checkReceiptHandler).Methods("POST") diff --git a/internal/api/live_activities.go b/internal/api/live_activities.go new file mode 100644 index 0000000..ac509f4 --- /dev/null +++ b/internal/api/live_activities.go @@ -0,0 +1,36 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/christianselig/apollo-backend/internal/domain" +) + +func (a *api) createLiveActivityHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + la := &domain.LiveActivity{} + if err := json.NewDecoder(r.Body).Decode(la); err != nil { + a.errorResponse(w, r, 500, err) + return + } + + ac := a.reddit.NewAuthenticatedClient(la.RedditAccountID, la.RefreshToken, la.AccessToken) + rtr, err := ac.RefreshTokens(ctx) + if err != nil { + a.errorResponse(w, r, 500, err) + return + } + + la.RefreshToken = rtr.RefreshToken + la.TokenExpiresAt = time.Now().Add(1 * time.Hour).UTC() + + if err := a.liveActivityRepo.Create(ctx, la); err != nil { + a.errorResponse(w, r, 500, err) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index e749f37..8a6368a 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -91,10 +91,16 @@ func SchedulerCmd(ctx context.Context) *cobra.Command { return err } + liveActivitiesQueue, err := queue.OpenQueue("live-activities") + if err != nil { + return err + } + s := gocron.NewScheduler(time.UTC) _, _ = s.Every(5).Seconds().SingletonMode().Do(func() { enqueueAccounts(ctx, logger, statsd, db, redis, luaSha, notifQueue) }) _, _ = s.Every(5).Second().Do(func() { enqueueSubreddits(ctx, logger, statsd, db, []rmq.Queue{subredditQueue, trendingQueue}) }) _, _ = s.Every(5).Second().Do(func() { enqueueUsers(ctx, logger, statsd, db, userQueue) }) + _, _ = s.Every(5).Second().Do(func() { enqueueLiveActivities(ctx, logger, db, redis, luaSha, liveActivitiesQueue) }) _, _ = s.Every(5).Second().Do(func() { cleanQueues(logger, queue) }) _, _ = s.Every(5).Second().Do(func() { enqueueStuckAccounts(ctx, logger, statsd, db, stuckNotificationsQueue) }) _, _ = s.Every(1).Minute().Do(func() { reportStats(ctx, logger, statsd, db) }) @@ -134,6 +140,57 @@ func evalScript(ctx context.Context, redis *redis.Client) (string, error) { return redis.ScriptLoad(ctx, lua).Result() } +func enqueueLiveActivities(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, redisConn *redis.Client, luaSha string, queue rmq.Queue) { + now := time.Now().UTC() + next := now.Add(domain.LiveActivityCheckInterval) + + stmt := `UPDATE live_activities + SET next_check_at = $2 + WHERE id IN ( + SELECT id + FROM live_activities + WHERE next_check_at < $1 + ORDER BY next_check_at + FOR UPDATE SKIP LOCKED + LIMIT 100 + ) + RETURNING live_activities.apns_token` + + ats := []string{} + + rows, err := pool.Query(ctx, stmt, now, next) + if err != nil { + logger.Error("failed to fetch batch of live activities", zap.Error(err)) + return + } + for rows.Next() { + var at string + _ = rows.Scan(&at) + ats = append(ats, at) + } + rows.Close() + + if len(ats) == 0 { + return + } + + batch, err := redisConn.EvalSha(ctx, luaSha, []string{"locks:live-activities"}, ats).StringSlice() + if err != nil { + logger.Error("failed to lock live activities", zap.Error(err)) + return + } + + if len(batch) == 0 { + return + } + + logger.Debug("enqueueing live activity batch", zap.Int("count", len(batch)), zap.Time("start", now)) + + if err = queue.Publish(batch...); err != nil { + logger.Error("failed to enqueue live activity batch", zap.Error(err)) + } +} + func pruneAccounts(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool) { expiry := time.Now().Add(-domain.StaleTokenThreshold) ar := repository.NewPostgresAccount(pool) diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index 692161d..94ffce8 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -13,6 +13,7 @@ import ( var ( queues = map[string]worker.NewWorkerFn{ + "live-activities": worker.NewLiveActivitiesWorker, "notifications": worker.NewNotificationsWorker, "stuck-notifications": worker.NewStuckNotificationsWorker, "subreddits": worker.NewSubredditsWorker, diff --git a/internal/domain/live_activity.go b/internal/domain/live_activity.go new file mode 100644 index 0000000..4a9bd50 --- /dev/null +++ b/internal/domain/live_activity.go @@ -0,0 +1,38 @@ +package domain + +import ( + "context" + "time" +) + +const ( + LiveActivityDuration = 75 * time.Minute + LiveActivityCheckInterval = 30 * time.Second +) + +type LiveActivity struct { + ID int64 + APNSToken string `json:"apns_token"` + Sandbox bool `json:"sandbox"` + + RedditAccountID string `json:"reddit_account_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenExpiresAt time.Time + + ThreadID string `json:"thread_id"` + Subreddit string `json:"subreddit"` + NextCheckAt time.Time + ExpiresAt time.Time +} + +type LiveActivityRepository interface { + Get(ctx context.Context, apnsToken string) (LiveActivity, error) + List(ctx context.Context) ([]LiveActivity, error) + + Create(ctx context.Context, la *LiveActivity) error + Update(ctx context.Context, la *LiveActivity) error + + RemoveStale(ctx context.Context) error + Delete(ctx context.Context, apns_token string) error +} diff --git a/internal/reddit/client.go b/internal/reddit/client.go index 2a6faa1..dc09692 100644 --- a/internal/reddit/client.go +++ b/internal/reddit/client.go @@ -594,3 +594,25 @@ func (rac *AuthenticatedClient) Me(ctx context.Context, opts ...RequestOption) ( } return mr.(*MeResponse), nil } + +func (rac *AuthenticatedClient) TopLevelComments(ctx context.Context, subreddit string, threadID string, opts ...RequestOption) (*ThreadResponse, error) { + url := fmt.Sprintf("https://oauth.reddit.com/r/%s/comments/%s/.json", subreddit, threadID) + + opts = append(rac.client.defaultOpts, opts...) + opts = append(opts, []RequestOption{ + WithTags([]string{"url:/comments"}), + WithMethod("GET"), + WithToken(rac.accessToken), + WithURL(url), + WithQuery("sort", "new"), + WithQuery("limit", "100"), + WithQuery("depth", "1"), + }...) + + req := NewRequest(opts...) + tr, err := rac.request(ctx, req, NewThreadResponse, nil) + if err != nil { + return nil, err + } + return tr.(*ThreadResponse), nil +} diff --git a/internal/reddit/testdata/thread.json b/internal/reddit/testdata/thread.json new file mode 100644 index 0000000..acf6b2e --- /dev/null +++ b/internal/reddit/testdata/thread.json @@ -0,0 +1,1734 @@ +[ + { + "kind": "Listing", + "data": { + "after": null, + "dist": 1, + "modhash": "", + "geo_filter": "", + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": null, + "subreddit": "SteamDeck", + "selftext": "", + "user_reports": [], + "saved": false, + "mod_reason_title": null, + "gilded": 0, + "clicked": false, + "title": "When you buy $400 machine to run games that you can run using $15 RPi", + "link_flair_richtext": [ + { + "e": "text", + "t": "Meme / Shitpost" + } + ], + "subreddit_name_prefixed": "r/SteamDeck", + "hidden": false, + "pwls": 6, + "link_flair_css_class": "meme", + "downs": 0, + "thumbnail_height": 140, + "top_awarded_type": null, + "parent_whitelist_status": "all_ads", + "hide_score": false, + "name": "t3_y70ane", + "quarantine": false, + "link_flair_text_color": "dark", + "upvote_ratio": 0.91, + "author_flair_background_color": null, + "ups": 783, + "domain": "i.redd.it", + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": null, + "is_original_content": false, + "author_fullname": "t2_y8f87", + "secure_media": null, + "is_reddit_media_domain": true, + "is_meta": false, + "category": null, + "secure_media_embed": {}, + "link_flair_text": "Meme / Shitpost", + "can_mod_post": false, + "score": 783, + "approved_by": null, + "is_created_from_ads_ui": false, + "author_premium": false, + "thumbnail": "https://b.thumbs.redditmedia.com/Qb00k1irfC9ciau_iBvh5DpNTlO3Dlv3FFYp9lSJoHE.jpg", + "edited": false, + "author_flair_css_class": null, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": null, + "is_self": false, + "subreddit_type": "public", + "created": 1666079110.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "allow_live_comments": false, + "selftext_html": null, + "likes": null, + "suggested_sort": null, + "banned_at_utc": null, + "url_overridden_by_dest": "https://i.redd.it/5kfmalflqiu91.jpg", + "view_count": null, + "archived": false, + "no_follow": false, + "is_crosspostable": false, + "pinned": false, + "over_18": false, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/5kfmalflqiu91.jpg?auto=webp&s=5a3cd50aaeb4ee7fff88ae3790fb8aa552b4dc1d", + "width": 500, + "height": 748 + }, + "resolutions": [ + { + "url": "https://preview.redd.it/5kfmalflqiu91.jpg?width=108&crop=smart&auto=webp&s=5176618d1e62d1dd3182f212456cbbc108aa2f26", + "width": 108, + "height": 161 + }, + { + "url": "https://preview.redd.it/5kfmalflqiu91.jpg?width=216&crop=smart&auto=webp&s=0ae4deb4f8dbe0c35df4a0a188b1b0a38085b3c1", + "width": 216, + "height": 323 + }, + { + "url": "https://preview.redd.it/5kfmalflqiu91.jpg?width=320&crop=smart&auto=webp&s=6ef33a9bea6b3374eb5673c1435d9e4dab819374", + "width": 320, + "height": 478 + } + ], + "variants": {}, + "id": "uypkya6Moo5WaPF-uKP9tQ8W5bdV51ah0MzjkMBP5o4" + } + ], + "enabled": true + }, + "all_awardings": [], + "awarders": [], + "media_only": false, + "link_flair_template_id": "0d79933a-e664-11eb-a632-0e64a4acdafb", + "can_gild": false, + "spoiler": false, + "locked": false, + "author_flair_text": null, + "treatment_tags": [], + "visited": false, + "removed_by": null, + "mod_note": null, + "distinguished": null, + "subreddit_id": "t5_4rfocy", + "author_is_blocked": false, + "mod_reason_by": null, + "num_reports": null, + "removal_reason": null, + "link_flair_background_color": "#c4459b", + "id": "y70ane", + "is_robot_indexable": true, + "num_duplicates": 0, + "report_reasons": null, + "author": "fareius", + "discussion_type": null, + "num_comments": 95, + "send_replies": false, + "media": null, + "contest_mode": false, + "author_patreon_flair": false, + "author_flair_text_color": null, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/", + "whitelist_status": "all_ads", + "stickied": false, + "url": "https://i.redd.it/5kfmalflqiu91.jpg", + "subreddit_subscribers": 251803, + "created_utc": 1666079110.0, + "num_crossposts": 0, + "mod_reports": [], + "is_video": false + } + } + ], + "before": null + } + }, + { + "kind": "Listing", + "data": { + "after": null, + "dist": null, + "modhash": "", + "geo_filter": "", + "children": [ + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issnhvr", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "AnotherLexMan", + "can_mod_post": false, + "created_utc": 1666096595.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_3w1ebwqt", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "The Deck is a lot more portable than the Pi though.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issnhvr", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>The Deck is a lot more portable than the Pi though.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issnhvr/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096595.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issn7pe", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "PhonicUK", + "can_mod_post": false, + "created_utc": 1666096448.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_3netb", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Good luck getting a Pi for that price! They're surprisingly hard to get hold of right now due to supply issues.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issn7pe", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Good luck getting a Pi for that price! They&#39;re surprisingly hard to get hold of right now due to supply issues.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": true, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issn7pe/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096448.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issmztp", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "crono141", + "can_mod_post": false, + "created_utc": 1666096331.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_16qws1", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I think there are phases of SteamDeck ownership. Emulation is part of the OMG NEW TOY phase where you are throwing literally everything at it to see what sticks. For probably the first 2 weeks all I played and tweaked was my collection of rims, just to see what it could do. Eventually I've moved on to my backlog and other interesting titles that are not from 15 year old systems.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issmztp", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I think there are phases of SteamDeck ownership. Emulation is part of the OMG NEW TOY phase where you are throwing literally everything at it to see what sticks. For probably the first 2 weeks all I played and tweaked was my collection of rims, just to see what it could do. Eventually I&#39;ve moved on to my backlog and other interesting titles that are not from 15 year old systems.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issmztp/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096331.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issmqqo", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "HerLegz", + "can_mod_post": false, + "created_utc": 1666096197.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_emhpv", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Foundational games. Don't be ageist.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issmqqo", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Foundational games. Don&#39;t be ageist.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issmqqo/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096197.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issmjf9", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ikradex", + "can_mod_post": false, + "created_utc": 1666096085.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_ac1uo", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Lol since when did you not need a monitor/keyboard/mouse? Also I'd love to know what year you think this is that $15 is MSRP or even a realistic price you'll find an RPi for second hand these days.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issmjf9", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Lol since when did you not need a monitor/keyboard/mouse? Also I&#39;d love to know what year you think this is that $15 is MSRP or even a realistic price you&#39;ll find an RPi for second hand these days.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issmjf9/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096085.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issmf8j", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "Professional-Ad-9047", + "can_mod_post": false, + "created_utc": 1666096022.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_7kmgsafj", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Well give me that RPI with a screen, controller and battery pack then.....", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issmf8j", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Well give me that RPI with a screen, controller and battery pack then.....</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issmf8j/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666096022.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "isslv1j", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "RealSkyDiver", + "can_mod_post": false, + "created_utc": 1666095716.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_ijmk7", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I was literally just setting up the master system and game gear games lol. I love how using overlay and the right shader with lcd ghosting makes it really feel like I’m playing on a real Game Gear. It has the size and weight. I even set the brightness to the lowest level. The less I see the more accurate the experience.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_isslv1j", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I was literally just setting up the master system and game gear games lol. I love how using overlay and the right shader with lcd ghosting makes it really feel like I’m playing on a real Game Gear. It has the size and weight. I even set the brightness to the lowest level. The less I see the more accurate the experience.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/isslv1j/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666095716.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issloz6", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "AstronautGuy42", + "can_mod_post": false, + "created_utc": 1666095625.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_173qce", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Actually haven’t used it to emulate yet. Been having a blast with temtem and other indies", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issloz6", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Actually haven’t used it to emulate yet. Been having a blast with temtem and other indies</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issloz6/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666095625.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "isslmu0", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "CaseroRubical", + "can_mod_post": false, + "created_utc": 1666095593.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_4j5zgrd", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Is there a tutorial or smth to make a rpi handheld emulator?", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_isslmu0", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Is there a tutorial or smth to make a rpi handheld emulator?</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/isslmu0/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666095593.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issl81j", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "Underleft_cdiv", + "can_mod_post": false, + "created_utc": 1666095365.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_j9y3q", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I swear I'm the only one that plays nothing but AAA games on this thing. I think it's amazing it can do it.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issl81j", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I swear I&#39;m the only one that plays nothing but AAA games on this thing. I think it&#39;s amazing it can do it.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issl81j/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666095365.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "isskwsu", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "redheadlizzy223", + "can_mod_post": false, + "created_utc": 1666095188.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_mi7iklv7", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "This is ALL I use my steam deck for.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_isskwsu", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>This is ALL I use my steam deck for.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": true, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/isskwsu/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666095188.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "richtext", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": "98aadfd0-9669-11ec-9b86-7e70422c021e", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issk0ii", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "Tesser_Wolf", + "can_mod_post": false, + "created_utc": 1666094673.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 2, + "author_fullname": "t2_40zqr9e9", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I wish I could buy some pi’s so many projects no non scalped pi 3’s or 4’s", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issk0ii", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [ + { + "e": "text", + "t": "256GB" + } + ], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I wish I could buy some pi’s so many projects no non scalped pi 3’s or 4’s</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": true, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": "dark", + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issk0ii/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666094673.0, + "author_flair_text": "256GB", + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": "transparent", + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 2 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issj4i7", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "reject423", + "can_mod_post": false, + "created_utc": 1666094153.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 2, + "author_fullname": "t2_iimi7", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I love it when people think you can still actually buy RPi and at retail price even. Stuffs been OOS for 2 years now basically.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issj4i7", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I love it when people think you can still actually buy RPi and at retail price even. Stuffs been OOS for 2 years now basically.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issj4i7/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666094153.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 2 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issidb5", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "PM_ME_YOUR_OPCODES", + "can_mod_post": false, + "created_utc": 1666093699.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_gv21aiyr", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "And vampire survivors", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issidb5", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>And vampire survivors</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issidb5/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666093699.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "isshxiv", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "catstroker69", + "can_mod_post": false, + "created_utc": 1666093426.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_4kvci2lf", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Skyrim machine go brrrrr", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_isshxiv", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Skyrim machine go brrrrr</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/isshxiv/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666093426.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "richtext", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": "9c6b8318-9669-11ec-aa39-7e44b10aab20", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issh1zp", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "Picobacsi", + "can_mod_post": false, + "created_utc": 1666092872.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_6cqwep43", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "right now my kid is watching cartoons via youtube on the deck... 😅", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issh1zp", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [ + { + "e": "text", + "t": "512GB" + } + ], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>right now my kid is watching cartoons via youtube on the deck... 😅</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": "dark", + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issh1zp/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666092872.0, + "author_flair_text": "512GB", + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": "transparent", + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issgm2h", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "HuntsmenSuperSaiyans", + "can_mod_post": false, + "created_utc": 1666092586.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 2, + "author_fullname": "t2_1sup9a26", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "In fairness, the Steam Deck is powerful enough that you can use runahead for better input latency. No $15 handheld can manage that.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issgm2h", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>In fairness, the Steam Deck is powerful enough that you can use runahead for better input latency. No $15 handheld can manage that.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issgm2h/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666092586.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 2 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issfs5q", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "Redsoxbox", + "can_mod_post": false, + "created_utc": 1666092045.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 1, + "author_fullname": "t2_mhxco31v", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "I paid $700.", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issfs5q", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>I paid $700.</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issfs5q/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666092045.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 1 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "richtext", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": "05c56a9a-efd0-11eb-ae84-6e65f558bfb6", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issfgmp", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "TheRealDealTys", + "can_mod_post": false, + "created_utc": 1666091834.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 2, + "author_fullname": "t2_56lkrz33", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "Lol never got into emulation because of how in depth it is but sounds fun! I usually to stick to steam games", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issfgmp", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [ + { + "e": "text", + "t": "256GB - Q2" + } + ], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>Lol never got into emulation because of how in depth it is but sounds fun! I usually to stick to steam games</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": "dark", + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issfgmp/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666091834.0, + "author_flair_text": "256GB - Q2", + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": "transparent", + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 2 + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_4rfocy", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "awarders": [], + "mod_reason_by": null, + "banned_by": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "SteamDeck", + "author_flair_template_id": null, + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "issek3m", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "IsDaedalus", + "can_mod_post": false, + "created_utc": 1666091227.0, + "send_replies": true, + "parent_id": "t3_y70ane", + "score": 2, + "author_fullname": "t2_iy2ul", + "approved_by": null, + "mod_note": null, + "all_awardings": [], + "collapsed": false, + "body": "You play NMS!", + "edited": false, + "top_awarded_type": null, + "author_flair_css_class": null, + "name": "t1_issek3m", + "is_submitter": false, + "downs": 0, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><p>You play NMS!</p>\n</div>", + "removal_reason": null, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "gildings": {}, + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/SteamDeck/comments/y70ane/when_you_buy_400_machine_to_run_games_that_you/issek3m/", + "subreddit_type": "public", + "locked": false, + "report_reasons": null, + "created": 1666091227.0, + "author_flair_text": null, + "treatment_tags": [], + "link_id": "t3_y70ane", + "subreddit_name_prefixed": "r/SteamDeck", + "controversiality": 0, + "depth": 0, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "num_reports": null, + "ups": 2 + } + }, + { + "kind": "more", + "data": { + "count": 77, + "name": "t1_issefwh", + "id": "issefwh", + "parent_id": "t3_y70ane", + "depth": 0, + "children": [ + "issefwh", + "issd66b", + "iss35e0", + "iss2ebv", + "issbp58", + "iss6mfh", + "iss9i0z", + "issbteg", + "iss60f0", + "iss1voh", + "iss4rs4", + "issa6cw", + "iss6hum", + "issdn3q", + "iss9ube", + "iss2ipn", + "isse3lp", + "iss811e", + "iss60zv", + "iss2kei", + "issclho", + "iss0gnh", + "iss3uj2", + "iss6ehb", + "iss1jgy", + "isse866", + "iss6kte", + "issboc6", + "issebcn", + "iss7p8b" + ] + } + } + ], + "before": null + } + } +] diff --git a/internal/reddit/types.go b/internal/reddit/types.go index 087256e..3794df9 100644 --- a/internal/reddit/types.go +++ b/internal/reddit/types.go @@ -63,6 +63,27 @@ func NewMeResponse(val *fastjson.Value) interface{} { return mr } +type ThreadResponse struct { + Post *Thing + Children []*Thing +} + +func NewThreadResponse(val *fastjson.Value) interface{} { + t := &ThreadResponse{} + listings := val.GetArray() + + // Thread details comes in the first element of the array as a one item listing + t.Post = NewThing(listings[0].Get("data").GetArray("children")[0]) + + // Comments come in the second element of the array also as a listing + comments := listings[1].Get("data").GetArray("children") + t.Children = make([]*Thing, len(comments)-1) + for i, comment := range comments[:len(comments)-1] { + t.Children[i] = NewThing(comment) + } + return t +} + type Thing struct { Kind string `json:"kind"` ID string `json:"id"` @@ -84,6 +105,7 @@ type Thing struct { Flair string `json:"flair"` Thumbnail string `json:"thumbnail"` Over18 bool `json:"over_18"` + NumComments int `json:"num_comments"` } func (t *Thing) FullName() string { @@ -122,6 +144,7 @@ func NewThing(val *fastjson.Value) *Thing { t.Flair = string(data.GetStringBytes("link_flair_text")) t.Thumbnail = string(data.GetStringBytes("thumbnail")) t.Over18 = data.GetBool("over_18") + t.NumComments = data.GetInt("num_comments") return t } diff --git a/internal/reddit/types_test.go b/internal/reddit/types_test.go index 5cae384..adc5eae 100644 --- a/internal/reddit/types_test.go +++ b/internal/reddit/types_test.go @@ -178,3 +178,24 @@ func TestUserPostsParsing(t *testing.T) { assert.Equal(t, "public", post.SubredditType) } + +func TestThreadResponseParsing(t *testing.T) { + t.Parallel() + + bb, err := ioutil.ReadFile("testdata/thread.json") + assert.NoError(t, err) + + parser := NewTestParser(t) + val, err := parser.ParseBytes(bb) + assert.NoError(t, err) + + ret := reddit.NewThreadResponse(val) + tr := ret.(*reddit.ThreadResponse) + assert.NotNil(t, tr) + + assert.Equal(t, "When you buy $400 machine to run games that you can run using $15 RPi", tr.Post.Title) + assert.Equal(t, 20, len(tr.Children)) + + assert.Equal(t, "The Deck is a lot more portable than the Pi though.", tr.Children[0].Body) + assert.Equal(t, "PhonicUK", tr.Children[1].Author) +} diff --git a/internal/repository/postgres_live_activity.go b/internal/repository/postgres_live_activity.go new file mode 100644 index 0000000..78de83a --- /dev/null +++ b/internal/repository/postgres_live_activity.go @@ -0,0 +1,123 @@ +package repository + +import ( + "context" + "time" + + "github.com/christianselig/apollo-backend/internal/domain" +) + +type postgresLiveActivityRepository struct { + conn Connection +} + +func NewPostgresLiveActivity(conn Connection) domain.LiveActivityRepository { + return &postgresLiveActivityRepository{conn: conn} +} + +func (p *postgresLiveActivityRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.LiveActivity, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var las []domain.LiveActivity + for rows.Next() { + var la domain.LiveActivity + if err := rows.Scan( + &la.ID, + &la.APNSToken, + &la.Sandbox, + &la.RedditAccountID, + &la.AccessToken, + &la.RefreshToken, + &la.TokenExpiresAt, + &la.ThreadID, + &la.Subreddit, + &la.NextCheckAt, + &la.ExpiresAt, + ); err != nil { + return nil, err + } + las = append(las, la) + } + return las, nil +} + +func (p *postgresLiveActivityRepository) Get(ctx context.Context, apnsToken string) (domain.LiveActivity, error) { + query := ` + SELECT id, apns_token, sandbox, reddit_account_id, access_token, refresh_token, token_expires_at, thread_id, subreddit, next_check_at, expires_at + FROM live_activities + WHERE apns_token = $1` + + las, err := p.fetch(ctx, query, apnsToken) + + if err != nil { + return domain.LiveActivity{}, err + } + if len(las) == 0 { + return domain.LiveActivity{}, domain.ErrNotFound + } + return las[0], nil +} + +func (p *postgresLiveActivityRepository) List(ctx context.Context) ([]domain.LiveActivity, error) { + query := ` + SELECT id, apns_token, sandbox, reddit_account_id, access_token, refresh_token, token_expires_at, thread_id, subreddit, next_check_at, expires_at + FROM live_activities + WHERE expires_at > NOW()` + + return p.fetch(ctx, query) +} + +func (p *postgresLiveActivityRepository) Create(ctx context.Context, la *domain.LiveActivity) error { + query := ` + INSERT INTO live_activities (apns_token, sandbox, reddit_account_id, access_token, refresh_token, token_expires_at, thread_id, subreddit, next_check_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (apns_token) DO UPDATE SET expires_at = $10 + RETURNING id` + + return p.conn.QueryRow(ctx, query, + la.APNSToken, + la.Sandbox, + la.RedditAccountID, + la.AccessToken, + la.RefreshToken, + la.TokenExpiresAt, + la.ThreadID, + la.Subreddit, + time.Now().UTC(), + time.Now().Add(domain.LiveActivityDuration).UTC(), + ).Scan(&la.ID) +} + +func (p *postgresLiveActivityRepository) Update(ctx context.Context, la *domain.LiveActivity) error { + query := ` + UPDATE live_activities + SET access_token = $1, refresh_token = $2, token_expires_at = $3, next_check_at = $4 + WHERE id = $5` + + _, err := p.conn.Exec(ctx, query, + la.AccessToken, + la.RefreshToken, + la.TokenExpiresAt, + la.NextCheckAt, + la.ID, + ) + return err +} + +func (p *postgresLiveActivityRepository) RemoveStale(ctx context.Context) error { + query := `DELETE FROM live_activities WHERE expires_at < NOW()` + + _, err := p.conn.Exec(ctx, query) + return err +} + +func (p *postgresLiveActivityRepository) Delete(ctx context.Context, apns_token string) error { + query := `DELETE FROM live_activities WHERE apns_token = $1` + + _, err := p.conn.Exec(ctx, query, apns_token) + return err +} diff --git a/internal/worker/live_activities.go b/internal/worker/live_activities.go new file mode 100644 index 0000000..3aac3e9 --- /dev/null +++ b/internal/worker/live_activities.go @@ -0,0 +1,312 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "time" + + "github.com/DataDog/datadog-go/statsd" + "github.com/adjust/rmq/v4" + "github.com/go-redis/redis/v8" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/sideshow/apns2" + "github.com/sideshow/apns2/token" + "go.uber.org/zap" + + "github.com/christianselig/apollo-backend/internal/domain" + "github.com/christianselig/apollo-backend/internal/reddit" + "github.com/christianselig/apollo-backend/internal/repository" +) + +type DynamicIslandNotification struct { + PostCommentCount int `json:"postTotalComments"` + PostScore int64 `json:"postScore"` + CommentAuthor string `json:"commentAuthor"` + CommentBody string `json:"commentBody"` + CommentAge int64 `json:"commentAge"` + CommentScore int64 `json:"commentScore"` +} + +type liveActivitiesWorker struct { + context.Context + + logger *zap.Logger + statsd *statsd.Client + db *pgxpool.Pool + redis *redis.Client + queue rmq.Connection + reddit *reddit.Client + apns *token.Token + + consumers int + + liveActivityRepo domain.LiveActivityRepository +} + +func NewLiveActivitiesWorker(ctx context.Context, logger *zap.Logger, statsd *statsd.Client, db *pgxpool.Pool, redis *redis.Client, queue rmq.Connection, consumers int) Worker { + reddit := reddit.NewClient( + os.Getenv("REDDIT_CLIENT_ID"), + os.Getenv("REDDIT_CLIENT_SECRET"), + statsd, + redis, + consumers, + ) + + var apns *token.Token + { + authKey, err := token.AuthKeyFromFile(os.Getenv("APPLE_KEY_PATH")) + if err != nil { + panic(err) + } + + apns = &token.Token{ + AuthKey: authKey, + KeyID: os.Getenv("APPLE_KEY_ID"), + TeamID: os.Getenv("APPLE_TEAM_ID"), + } + } + + return &liveActivitiesWorker{ + ctx, + logger, + statsd, + db, + redis, + queue, + reddit, + apns, + consumers, + + repository.NewPostgresLiveActivity(db), + } +} + +func (law *liveActivitiesWorker) Start() error { + queue, err := law.queue.OpenQueue("live-activities") + if err != nil { + return err + } + + law.logger.Info("starting up live activities worker", zap.Int("consumers", law.consumers)) + + prefetchLimit := int64(law.consumers * 4) + + if err := queue.StartConsuming(prefetchLimit, pollDuration); err != nil { + return err + } + + host, _ := os.Hostname() + + for i := 0; i < law.consumers; i++ { + name := fmt.Sprintf("consumer %s-%d", host, i) + + consumer := NewLiveActivitiesConsumer(law, i) + if _, err := queue.AddConsumer(name, consumer); err != nil { + return err + } + } + + return nil +} + +func (law *liveActivitiesWorker) Stop() { + <-law.queue.StopAllConsuming() // wait for all Consume() calls to finish +} + +type liveActivitiesConsumer struct { + *liveActivitiesWorker + tag int + + apnsSandbox *apns2.Client + apnsProduction *apns2.Client +} + +func NewLiveActivitiesConsumer(law *liveActivitiesWorker, tag int) *liveActivitiesConsumer { + return &liveActivitiesConsumer{ + law, + tag, + apns2.NewTokenClient(law.apns), + apns2.NewTokenClient(law.apns).Production(), + } +} + +func (lac *liveActivitiesConsumer) Consume(delivery rmq.Delivery) { + now := time.Now().UTC() + defer func() { + elapsed := time.Now().Sub(now).Milliseconds() + _ = lac.statsd.Histogram("apollo.consumer.runtime", float64(elapsed), []string{"queue:live_activities"}, 0.1) + }() + + at := delivery.Payload() + key := fmt.Sprintf("locks:live-activities:%s", at) + + // Measure queue latency + ttl := lac.redis.TTL(lac, key).Val() + age := (domain.NotificationCheckTimeout - ttl) + _ = lac.statsd.Histogram("apollo.dequeue.latency", float64(age.Milliseconds()), []string{"queue:live_activities"}, 0.1) + + defer func() { + if err := lac.redis.Del(lac, key).Err(); err != nil { + lac.logger.Error("failed to remove account lock", zap.Error(err), zap.String("key", key)) + } + }() + + lac.logger.Debug("starting job", zap.String("live_activity#apns_token", at)) + + defer func() { + if err := delivery.Ack(); err != nil { + lac.logger.Error("failed to acknowledge message", zap.Error(err), zap.String("live_activity#apns_token", at)) + } + }() + + la, err := lac.liveActivityRepo.Get(lac, at) + if err != nil { + lac.logger.Error("failed to get live activity", zap.Error(err), zap.String("live_activity#apns_token", at)) + return + } + + rac := lac.reddit.NewAuthenticatedClient(la.RedditAccountID, la.RefreshToken, la.AccessToken) + if la.TokenExpiresAt.Before(now.Add(5 * time.Minute)) { + lac.logger.Debug("refreshing reddit token", + zap.String("live_activity#apns_token", at), + ) + + tokens, err := rac.RefreshTokens(lac) + if err != nil { + if err != reddit.ErrOauthRevoked { + lac.logger.Error("failed to refresh reddit tokens", + zap.Error(err), + zap.String("live_activity#apns_token", at), + ) + return + } + + err = lac.liveActivityRepo.Delete(lac, at) + if err != nil { + lac.logger.Error("failed to remove revoked account", + zap.Error(err), + zap.String("live_activity#apns_token", at), + ) + } + + return + } + + // Update account + la.AccessToken = tokens.AccessToken + la.RefreshToken = tokens.RefreshToken + la.TokenExpiresAt = now.Add(tokens.Expiry) + _ = lac.liveActivityRepo.Update(lac, &la) + + // Refresh client + rac = lac.reddit.NewAuthenticatedClient(la.RedditAccountID, tokens.RefreshToken, tokens.AccessToken) + } + + lac.logger.Debug("fetching latest comments", zap.String("live_activity#apns_token", at)) + + tr, err := rac.TopLevelComments(lac, la.Subreddit, la.ThreadID) + if err != nil { + lac.logger.Error("failed to fetch latest comments", + zap.Error(err), + zap.String("live_activity#apns_token", at), + ) + return + } + + if len(tr.Children) == 0 { + lac.logger.Debug("no comments found", zap.String("live_activity#apns_token", at)) + return + } + + // Filter out comments in the last minute + candidates := make([]*reddit.Thing, 0) + cutoff := now.Add(-domain.LiveActivityCheckInterval) + for _, t := range tr.Children { + if t.CreatedAt.After(cutoff) { + candidates = append(candidates, t) + } + } + + if len(candidates) == 0 { + lac.logger.Debug("no new comments found", zap.String("live_activity#apns_token", at)) + return + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].Score > candidates[j].Score + }) + + comment := candidates[0] + + din := DynamicIslandNotification{ + PostCommentCount: tr.Post.NumComments, + PostScore: tr.Post.Score, + CommentAuthor: comment.Author, + CommentBody: comment.Body, + CommentAge: comment.CreatedAt.Unix(), + CommentScore: comment.Score, + } + + ev := "update" + if la.ExpiresAt.Before(now) { + ev = "end" + } + + pl := map[string]interface{}{ + "aps": map[string]interface{}{ + "timestamp": time.Now().Unix(), + "event": ev, + "content-state": din, + }, + } + bb, _ := json.Marshal(pl) + + notification := &apns2.Notification{ + DeviceToken: la.APNSToken, + Topic: "com.christianselig.Apollo.push-type.liveactivity", + PushType: "liveactivity", + Payload: bb, + } + + client := lac.apnsProduction + if la.Sandbox { + client = lac.apnsSandbox + } + + res, err := client.PushWithContext(lac, notification) + if err != nil { + _ = lac.statsd.Incr("apns.live_activities.errors", []string{}, 1) + lac.logger.Error("failed to send notification", + zap.Error(err), + zap.String("live_activity#apns_token", at), + ) + + _ = lac.liveActivityRepo.Delete(lac, at) + } else if !res.Sent() { + _ = lac.statsd.Incr("apns.live_activities.errors", []string{}, 1) + lac.logger.Error("notification not sent", + zap.String("live_activity#apns_token", at), + zap.Int("response#status", res.StatusCode), + zap.String("response#reason", res.Reason), + ) + + _ = lac.liveActivityRepo.Delete(lac, at) + } else { + _ = lac.statsd.Incr("apns.live_activities.sent", []string{}, 1) + lac.logger.Info("sent notification", + zap.String("live_activity#apns_token", at), + ) + } + + if la.ExpiresAt.Before(now) { + lac.logger.Debug("live activity expired, deleting", zap.String("live_activity#apns_token", at)) + _ = lac.liveActivityRepo.Delete(lac, at) + } + + lac.logger.Debug("finishing job", + zap.String("live_activity#apns_token", at), + ) +} diff --git a/internal/worker/notifications.go b/internal/worker/notifications.go index 7a49404..dc806f2 100644 --- a/internal/worker/notifications.go +++ b/internal/worker/notifications.go @@ -321,7 +321,7 @@ func (nc *notificationsConsumer) Consume(delivery rmq.Delivery) { client = nc.apnsSandbox } - res, err := client.Push(notification) + res, err := client.PushWithContext(nc, notification) if err != nil { _ = nc.statsd.Incr("apns.notification.errors", []string{}, 1) nc.logger.Error("failed to send notification",