apollo-backend/internal/worker/notifications.go

454 lines
12 KiB
Go
Raw Normal View History

package worker
2021-07-08 23:03:46 +00:00
import (
"context"
"fmt"
"os"
"strconv"
"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/payload"
"github.com/sideshow/apns2/token"
"github.com/sirupsen/logrus"
2021-08-14 17:42:28 +00:00
"github.com/christianselig/apollo-backend/internal/domain"
2021-07-08 23:03:46 +00:00
"github.com/christianselig/apollo-backend/internal/reddit"
2021-08-14 17:42:28 +00:00
"github.com/christianselig/apollo-backend/internal/repository"
2021-07-08 23:03:46 +00:00
)
const (
backoff = 5 // How long we wait in between checking for notifications, in seconds
2021-07-13 23:45:03 +00:00
pollDuration = 5 * time.Millisecond
2021-07-08 23:26:15 +00:00
rate = 0.1
2021-07-08 23:03:46 +00:00
)
type notificationsWorker struct {
2021-08-14 17:42:28 +00:00
logger *logrus.Logger
statsd *statsd.Client
db *pgxpool.Pool
redis *redis.Client
queue rmq.Connection
reddit *reddit.Client
apns *token.Token
2021-07-14 00:09:44 +00:00
consumers int
2021-08-14 17:42:28 +00:00
accountRepo domain.AccountRepository
deviceRepo domain.DeviceRepository
}
2021-07-08 23:03:46 +00:00
2021-07-14 00:09:44 +00:00
func NewNotificationsWorker(logger *logrus.Logger, statsd *statsd.Client, db *pgxpool.Pool, redis *redis.Client, queue rmq.Connection, consumers int) Worker {
reddit := reddit.NewClient(
2021-07-08 23:03:46 +00:00
os.Getenv("REDDIT_CLIENT_ID"),
os.Getenv("REDDIT_CLIENT_SECRET"),
statsd,
2021-07-14 00:09:44 +00:00
consumers,
2021-07-08 23:03:46 +00:00
)
var apns *token.Token
2021-07-08 23:03:46 +00:00
{
2021-07-23 00:32:37 +00:00
authKey, err := token.AuthKeyFromFile(os.Getenv("APPLE_KEY_PATH"))
2021-07-08 23:03:46 +00:00
if err != nil {
panic(err)
}
apns = &token.Token{
2021-07-08 23:03:46 +00:00
AuthKey: authKey,
KeyID: os.Getenv("APPLE_KEY_ID"),
TeamID: os.Getenv("APPLE_TEAM_ID"),
}
}
return &notificationsWorker{
logger,
statsd,
db,
redis,
queue,
reddit,
apns,
2021-07-14 00:09:44 +00:00
consumers,
2021-08-14 17:42:28 +00:00
repository.NewPostgresAccount(db),
repository.NewPostgresDevice(db),
2021-07-08 23:03:46 +00:00
}
}
2021-07-08 23:03:46 +00:00
2021-07-14 00:09:44 +00:00
func (nw *notificationsWorker) Start() error {
queue, err := nw.queue.OpenQueue("notifications")
2021-07-08 23:03:46 +00:00
if err != nil {
return err
2021-07-08 23:03:46 +00:00
}
nw.logger.WithFields(logrus.Fields{
2021-07-14 00:09:44 +00:00
"numConsumers": nw.consumers,
2021-07-09 03:50:34 +00:00
}).Info("starting up notifications worker")
2021-07-09 01:28:43 +00:00
2021-07-14 00:09:44 +00:00
prefetchLimit := int64(nw.consumers * 2)
2021-07-08 23:03:46 +00:00
if err := queue.StartConsuming(prefetchLimit, pollDuration); err != nil {
return err
2021-07-08 23:03:46 +00:00
}
2021-07-09 03:50:34 +00:00
host, _ := os.Hostname()
2021-07-14 00:09:44 +00:00
for i := 0; i < nw.consumers; i++ {
2021-07-09 03:50:34 +00:00
name := fmt.Sprintf("consumer %s-%d", host, i)
2021-07-08 23:03:46 +00:00
consumer := NewNotificationsConsumer(nw, i)
2021-07-08 23:03:46 +00:00
if _, err := queue.AddConsumer(name, consumer); err != nil {
return err
2021-07-08 23:03:46 +00:00
}
}
return nil
}
2021-07-08 23:03:46 +00:00
func (nw *notificationsWorker) Stop() {
<-nw.queue.StopAllConsuming() // wait for all Consume() calls to finish
2021-07-08 23:03:46 +00:00
}
type notificationsConsumer struct {
*notificationsWorker
tag int
2021-07-08 23:03:46 +00:00
apnsSandbox *apns2.Client
apnsProduction *apns2.Client
}
func NewNotificationsConsumer(nw *notificationsWorker, tag int) *notificationsConsumer {
return &notificationsConsumer{
nw,
2021-07-08 23:03:46 +00:00
tag,
apns2.NewTokenClient(nw.apns),
apns2.NewTokenClient(nw.apns).Production(),
2021-07-08 23:03:46 +00:00
}
}
func (nc *notificationsConsumer) Consume(delivery rmq.Delivery) {
2021-07-08 23:03:46 +00:00
ctx := context.Background()
2021-07-09 00:37:39 +00:00
defer func() {
2021-07-09 01:44:06 +00:00
lockKey := fmt.Sprintf("locks:accounts:%s", delivery.Payload())
if err := nc.redis.Del(ctx, lockKey).Err(); err != nil {
nc.logger.WithFields(logrus.Fields{
2021-07-09 01:44:06 +00:00
"lockKey": lockKey,
"err": err,
}).Error("failed to remove lock")
}
2021-07-09 00:37:39 +00:00
}()
2021-07-09 00:14:12 +00:00
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#id": delivery.Payload(),
2021-07-08 23:03:46 +00:00
}).Debug("starting job")
id, err := strconv.ParseInt(delivery.Payload(), 10, 64)
if err != nil {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#id": delivery.Payload(),
"err": err,
2021-07-08 23:03:46 +00:00
}).Error("failed to parse account ID")
2021-09-25 13:19:42 +00:00
_ = delivery.Reject()
2021-07-08 23:03:46 +00:00
return
}
2021-09-25 13:19:42 +00:00
defer func() { _ = delivery.Ack() }()
2021-07-09 03:50:34 +00:00
2021-07-08 23:03:46 +00:00
now := float64(time.Now().UnixNano()/int64(time.Millisecond)) / 1000
2021-08-14 17:42:28 +00:00
account, err := nc.accountRepo.GetByID(ctx, id)
if err != nil {
nc.logger.WithFields(logrus.Fields{
2021-09-25 16:56:01 +00:00
"err": err,
2021-07-08 23:03:46 +00:00
}).Error("failed to fetch account from database")
return
}
2021-08-14 18:15:05 +00:00
previousLastCheckedAt := account.LastCheckedAt
newAccount := (previousLastCheckedAt == 0)
2021-08-14 17:42:28 +00:00
account.LastCheckedAt = now
if err = nc.accountRepo.Update(ctx, &account); err != nil {
2021-07-13 17:58:52 +00:00
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
"err": err,
2021-07-13 17:58:52 +00:00
}).Error("failed to update last_checked_at for account")
return
}
rac := nc.reddit.NewAuthenticatedClient(account.RefreshToken, account.AccessToken)
2021-07-08 23:03:46 +00:00
if account.ExpiresAt < int64(now) {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
2021-07-08 23:03:46 +00:00
}).Debug("refreshing reddit token")
2021-07-08 23:26:15 +00:00
2021-07-08 23:03:46 +00:00
tokens, err := rac.RefreshTokens()
if err != nil {
2021-08-14 17:42:28 +00:00
if err != reddit.ErrOauthRevoked {
nc.logger.WithFields(logrus.Fields{
"account#username": account.NormalizedUsername(),
"err": err,
}).Error("failed to refresh reddit tokens")
return
}
err = nc.deleteAccount(ctx, account)
if err != nil {
nc.logger.WithFields(logrus.Fields{
"account#username": account.NormalizedUsername(),
"err": err,
}).Error("failed to remove revoked account")
return
}
2021-07-08 23:03:46 +00:00
}
2021-07-12 21:10:10 +00:00
2021-07-13 18:47:50 +00:00
// Update account
account.AccessToken = tokens.AccessToken
account.RefreshToken = tokens.RefreshToken
account.ExpiresAt = int64(now + 3540)
2021-07-12 21:10:10 +00:00
// Refresh client
rac = nc.reddit.NewAuthenticatedClient(tokens.RefreshToken, tokens.AccessToken)
2021-07-12 21:10:10 +00:00
2021-08-14 17:42:28 +00:00
if err = nc.accountRepo.Update(ctx, &account); err != nil {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
"err": err,
2021-07-08 23:03:46 +00:00
}).Error("failed to update reddit tokens for account")
return
}
}
2021-07-13 19:08:39 +00:00
// Only update delay on accounts we can actually check, otherwise it skews
// the numbers too much.
2021-08-14 17:42:28 +00:00
if !newAccount {
2021-08-14 18:15:05 +00:00
latency := now - previousLastCheckedAt - float64(backoff)
2021-09-25 13:19:42 +00:00
_ = nc.statsd.Histogram("apollo.queue.delay", latency, []string{}, rate)
2021-07-13 19:08:39 +00:00
}
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
2021-07-08 23:03:46 +00:00
}).Debug("fetching message inbox")
opts := []reddit.RequestOption{reddit.WithQuery("limit", "10")}
if account.LastMessageID != "" {
opts = append(opts, reddit.WithQuery("before", account.LastMessageID))
}
msgs, err := rac.MessageInbox(opts...)
2021-07-08 23:03:46 +00:00
if err != nil {
2021-08-14 18:07:19 +00:00
switch err {
case reddit.ErrTimeout: // Don't log timeouts
break
case reddit.ErrOauthRevoked:
err = nc.deleteAccount(ctx, account)
if err != nil {
nc.logger.WithFields(logrus.Fields{
"account#username": account.NormalizedUsername(),
"err": err,
}).Error("failed to remove revoked account")
return
}
2021-08-14 17:42:28 +00:00
nc.logger.WithFields(logrus.Fields{
"account#username": account.NormalizedUsername(),
2021-08-14 18:07:19 +00:00
}).Info("removed revoked account")
default:
2021-08-14 17:42:28 +00:00
nc.logger.WithFields(logrus.Fields{
"account#username": account.NormalizedUsername(),
"err": err,
2021-08-14 18:07:19 +00:00
}).Error("failed to fetch message inbox")
2021-08-14 17:42:28 +00:00
}
2021-07-08 23:03:46 +00:00
return
}
2021-07-15 14:51:34 +00:00
// Figure out where we stand
if msgs.Count == 0 {
2021-07-15 14:51:34 +00:00
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
2021-07-15 14:51:34 +00:00
}).Debug("no new messages, bailing early")
return
}
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
"count": msgs.Count,
2021-07-08 23:03:46 +00:00
}).Debug("fetched messages")
2021-10-17 15:05:40 +00:00
for i := msgs.Count - 1; i >= 0; i-- {
msg := msgs.Children[i]
if !msg.IsDeleted() {
account.LastMessageID = msgs.Children[0].FullName()
break
}
}
2021-08-14 17:42:28 +00:00
if err = nc.accountRepo.Update(ctx, &account); err != nil {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
"err": err,
}).Error("failed to update last_message_id for account")
return
}
2021-07-08 23:03:46 +00:00
// Let's populate this with the latest message so we don't flood users with stuff
2021-08-14 17:42:28 +00:00
if newAccount {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
}).Debug("populating first message ID to prevent spamming")
2021-07-08 23:03:46 +00:00
return
}
2021-08-14 17:42:28 +00:00
devices, err := nc.deviceRepo.GetByAccountID(ctx, account.ID)
2021-07-08 23:03:46 +00:00
if err != nil {
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
"err": err,
2021-07-08 23:03:46 +00:00
}).Error("failed to fetch account devices")
return
}
2021-07-15 14:51:34 +00:00
// Iterate backwards so we notify from older to newer
for i := msgs.Count - 1; i >= 0; i-- {
msg := msgs.Children[i]
2021-10-17 15:05:40 +00:00
if msg.IsDeleted() {
continue
}
2021-07-08 23:03:46 +00:00
notification := &apns2.Notification{}
notification.Topic = "com.christianselig.Apollo"
notification.Payload = payloadFromMessage(account, msg, msgs.Count)
2021-07-08 23:03:46 +00:00
for _, device := range devices {
notification.DeviceToken = device.APNSToken
client := nc.apnsProduction
2021-07-08 23:03:46 +00:00
if device.Sandbox {
client = nc.apnsSandbox
2021-07-08 23:03:46 +00:00
}
res, err := client.Push(notification)
if err != nil {
2021-09-25 13:19:42 +00:00
_ = nc.statsd.Incr("apns.notification.errors", []string{}, 1)
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:51:18 +00:00
"account#username": account.NormalizedUsername(),
"err": err,
"status": res.StatusCode,
"reason": res.Reason,
2021-07-08 23:03:46 +00:00
}).Error("failed to send notification")
2021-10-10 15:51:42 +00:00
// Delete device as notifications might have been disabled here
_ = nc.deviceRepo.Delete(ctx, device.APNSToken)
2021-07-08 23:03:46 +00:00
} else {
2021-09-25 13:19:42 +00:00
_ = nc.statsd.Incr("apns.notification.sent", []string{}, 1)
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:51:18 +00:00
"account#username": account.NormalizedUsername(),
"token": device.APNSToken,
2021-07-09 03:12:50 +00:00
}).Info("sent notification")
2021-07-08 23:03:46 +00:00
}
}
}
ev := fmt.Sprintf("Sent notification to /u/%s (x%d)", account.Username, msgs.Count)
2021-09-25 13:19:42 +00:00
_ = nc.statsd.SimpleEvent(ev, "")
2021-07-16 00:55:53 +00:00
nc.logger.WithFields(logrus.Fields{
2021-08-14 17:42:28 +00:00
"account#username": account.NormalizedUsername(),
2021-07-08 23:03:46 +00:00
}).Debug("finishing job")
}
2021-08-14 17:42:28 +00:00
func (nc *notificationsConsumer) deleteAccount(ctx context.Context, account domain.Account) error {
// Disassociate account from devices
devs, err := nc.deviceRepo.GetByAccountID(ctx, account.ID)
if err != nil {
return err
}
for _, dev := range devs {
if err := nc.accountRepo.Disassociate(ctx, &account, &dev); err != nil {
return err
}
}
return nc.accountRepo.Delete(ctx, account.ID)
}
func payloadFromMessage(acct domain.Account, msg *reddit.Thing, badgeCount int) *payload.Payload {
2021-07-10 18:51:42 +00:00
postBody := msg.Body
if len(postBody) > 2000 {
postBody = msg.Body[:2000]
}
postTitle := msg.LinkTitle
if postTitle == "" {
postTitle = msg.Subject
}
if len(postTitle) > 75 {
postTitle = fmt.Sprintf("%s…", postTitle[0:75])
}
2021-07-12 18:36:08 +00:00
payload := payload.
NewPayload().
AlertBody(postBody).
AlertSummaryArg(msg.Author).
Badge(badgeCount).
Custom("account_id", acct.AccountID).
Custom("author", msg.Author).
Custom("destination_author", msg.Destination).
Custom("parent_id", msg.ParentID).
Custom("post_title", msg.LinkTitle).
Custom("subreddit", msg.Subreddit).
MutableContent().
Sound("traloop.wav")
2021-07-10 18:51:42 +00:00
switch {
case (msg.Kind == "t1" && msg.Type == "username_mention"):
title := fmt.Sprintf(`Mention in “%s”`, postTitle)
payload = payload.AlertTitle(title).Custom("type", "username")
pType, _ := reddit.SplitID(msg.ParentID)
if pType == "t1" {
payload = payload.Category("inbox-username-mention-context")
} else {
payload = payload.Category("inbox-username-mention-no-context")
}
payload = payload.Custom("subject", "comment").ThreadID("comment")
case (msg.Kind == "t1" && msg.Type == "post_reply"):
title := fmt.Sprintf(`%s to “%s”`, msg.Author, postTitle)
2021-07-12 18:36:08 +00:00
payload = payload.
AlertTitle(title).
Category("inbox-post-reply").
Custom("post_id", msg.ID).
Custom("subject", "comment").
Custom("type", "post").
ThreadID("comment")
2021-07-10 18:51:42 +00:00
case (msg.Kind == "t1" && msg.Type == "comment_reply"):
title := fmt.Sprintf(`%s in “%s”`, msg.Author, postTitle)
2021-07-12 19:51:02 +00:00
postID := reddit.PostIDFromContext(msg.Context)
2021-07-12 18:36:08 +00:00
payload = payload.
AlertTitle(title).
Category("inbox-comment-reply").
Custom("comment_id", msg.ID).
Custom("post_id", postID).
Custom("subject", "comment").
Custom("type", "comment").
ThreadID("comment")
2021-07-10 18:51:42 +00:00
case (msg.Kind == "t4"):
title := fmt.Sprintf(`Message from %s`, msg.Author)
2021-07-12 18:36:08 +00:00
payload = payload.
AlertTitle(title).
AlertSubtitle(postTitle).
Category("inbox-private-message").
Custom("type", "private-message")
2021-07-10 18:51:42 +00:00
}
return payload
}