NewsBlur-viq/apps/notifications/models.py

452 lines
17 KiB
Python
Raw Normal View History

2016-11-14 11:12:13 -08:00
import datetime
import enum
2021-03-02 10:13:11 -05:00
import html
import re
2024-04-24 09:50:42 -04:00
import urllib.parse
2016-11-14 11:12:13 -08:00
import mongoengine as mongo
2024-04-24 09:50:42 -04:00
import redis
from apns2.client import APNsClient
from apns2.errors import BadDeviceToken, DeviceTokenNotForTopic, Unregistered
from apns2.payload import Payload
from bs4 import BeautifulSoup
2016-11-14 11:12:13 -08:00
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import EmailMultiAlternatives
2024-04-24 09:50:42 -04:00
from django.template.loader import render_to_string
2022-03-02 10:02:48 -05:00
from apps.analyzer.models import (
MClassifierAuthor,
MClassifierFeed,
MClassifierTag,
2024-04-24 09:50:42 -04:00
MClassifierTitle,
compute_story_score,
2022-03-02 10:02:48 -05:00
)
2024-04-24 09:50:42 -04:00
from apps.reader.models import UserSubscription
# from django.utils.html import strip_tags
from apps.rss_feeds.models import Feed, MStory
2016-11-14 11:12:13 -08:00
from utils import log as logging
from utils import mongoengine_fields
2024-04-24 09:50:42 -04:00
from utils.story_functions import truncate_chars
from utils.view_functions import is_true
2016-11-14 11:12:13 -08:00
2022-03-02 10:02:48 -05:00
2016-11-14 11:12:13 -08:00
class NotificationFrequency(enum.Enum):
immediately = 1
hour_1 = 2
hour_6 = 3
hour_12 = 4
hour_24 = 5
2022-03-02 10:02:48 -05:00
class MUserNotificationTokens(mongo.Document):
2024-04-24 09:43:56 -04:00
"""A user's push notification tokens"""
2022-03-02 10:02:48 -05:00
user_id = mongo.IntField()
ios_tokens = mongo.ListField(mongo.StringField(max_length=1024))
use_sandbox = mongo.BooleanField(default=False)
meta = {
2024-04-24 09:43:56 -04:00
"collection": "notification_tokens",
"indexes": [
2022-03-02 10:02:48 -05:00
{
2024-04-24 09:43:56 -04:00
"fields": ["user_id"],
"unique": True,
2022-03-02 10:02:48 -05:00
}
],
2024-04-24 09:43:56 -04:00
"allow_inheritance": False,
}
2022-03-02 10:02:48 -05:00
@classmethod
def get_tokens_for_user(cls, user_id):
try:
tokens = cls.objects.get(user_id=user_id)
except cls.DoesNotExist:
tokens = cls.objects.create(user_id=user_id)
return tokens
2016-11-14 11:12:13 -08:00
2022-03-02 10:02:48 -05:00
2016-11-14 11:12:13 -08:00
class MUserFeedNotification(mongo.Document):
2024-04-24 09:43:56 -04:00
"""A user's notifications of a single feed."""
2022-03-02 10:02:48 -05:00
user_id = mongo.IntField()
feed_id = mongo.IntField()
frequency = mongoengine_fields.IntEnumField(NotificationFrequency)
is_focus = mongo.BooleanField()
last_notification_date = mongo.DateTimeField(default=datetime.datetime.now)
is_email = mongo.BooleanField()
is_web = mongo.BooleanField()
is_ios = mongo.BooleanField()
is_android = mongo.BooleanField()
ios_tokens = mongo.ListField(mongo.StringField(max_length=1024))
2016-11-14 11:12:13 -08:00
meta = {
2024-04-24 09:43:56 -04:00
"collection": "notifications",
"indexes": [
"feed_id",
2022-03-02 10:02:48 -05:00
{
2024-04-24 09:43:56 -04:00
"fields": ["user_id", "feed_id"],
"unique": True,
2022-03-02 10:02:48 -05:00
},
],
2024-04-24 09:43:56 -04:00
"allow_inheritance": False,
2016-11-14 11:12:13 -08:00
}
2022-03-02 10:02:48 -05:00
def __str__(self):
2016-11-14 11:12:13 -08:00
notification_types = []
2022-03-02 10:02:48 -05:00
if self.is_email:
2024-04-24 09:43:56 -04:00
notification_types.append("email")
2022-03-02 10:02:48 -05:00
if self.is_web:
2024-04-24 09:43:56 -04:00
notification_types.append("web")
2022-03-02 10:02:48 -05:00
if self.is_ios:
2024-04-24 09:43:56 -04:00
notification_types.append("ios")
2022-03-02 10:02:48 -05:00
if self.is_android:
2024-04-24 09:43:56 -04:00
notification_types.append("android")
2016-11-14 11:12:13 -08:00
return "%s/%s: %s -> %s" % (
User.objects.get(pk=self.user_id).username,
Feed.get_by_id(self.feed_id),
2024-04-24 09:43:56 -04:00
",".join(notification_types),
self.last_notification_date,
2016-11-14 11:12:13 -08:00
)
2022-03-02 10:02:48 -05:00
@classmethod
def feed_has_users(cls, feed_id):
return cls.users_for_feed(feed_id).count()
2022-03-02 10:02:48 -05:00
@classmethod
def users_for_feed(cls, feed_id):
notifications = cls.objects.filter(feed_id=feed_id)
2022-03-02 10:02:48 -05:00
return notifications
2022-03-02 10:02:48 -05:00
@classmethod
def feeds_for_user(cls, user_id):
notifications = cls.objects.filter(user_id=user_id)
notifications_by_feed = {}
for feed in notifications:
notifications_by_feed[feed.feed_id] = {
2024-04-24 09:43:56 -04:00
"notification_types": [],
"notification_filter": "focus" if feed.is_focus else "unread",
}
2022-03-02 10:02:48 -05:00
if feed.is_email:
2024-04-24 09:43:56 -04:00
notifications_by_feed[feed.feed_id]["notification_types"].append("email")
2022-03-02 10:02:48 -05:00
if feed.is_web:
2024-04-24 09:43:56 -04:00
notifications_by_feed[feed.feed_id]["notification_types"].append("web")
2022-03-02 10:02:48 -05:00
if feed.is_ios:
2024-04-24 09:43:56 -04:00
notifications_by_feed[feed.feed_id]["notification_types"].append("ios")
2022-03-02 10:02:48 -05:00
if feed.is_android:
2024-04-24 09:43:56 -04:00
notifications_by_feed[feed.feed_id]["notification_types"].append("android")
2022-03-02 10:02:48 -05:00
return notifications_by_feed
2022-03-02 10:02:48 -05:00
@classmethod
def push_feed_notifications(cls, feed_id, new_stories, force=False):
feed = Feed.get_by_id(feed_id)
notifications = MUserFeedNotification.users_for_feed(feed.pk)
2022-03-02 10:02:48 -05:00
logging.debug(
" ---> [%-30s] ~FCPushing out notifications to ~SB%s users~SN for ~FB~SB%s stories"
% (feed, len(notifications), new_stories)
)
r = redis.Redis(connection_pool=settings.REDIS_STORY_HASH_POOL)
2022-03-02 10:02:48 -05:00
latest_story_hashes = r.zrange("zF:%s" % feed.pk, -1 * new_stories, -1)
2024-04-24 09:43:56 -04:00
mstories = MStory.objects.filter(story_hash__in=latest_story_hashes).order_by("-story_date")
stories = Feed.format_stories(mstories)
2016-11-21 17:36:21 -08:00
total_sent_count = 0
2022-03-02 10:02:48 -05:00
for user_feed_notification in notifications:
sent_count = 0
try:
user = User.objects.get(pk=user_feed_notification.user_id)
except User.DoesNotExist:
continue
months_ago = datetime.datetime.now() - datetime.timedelta(days=90)
if user.profile.last_seen_on < months_ago:
logging.user(user, f"~FBSkipping notifications, last seen: ~SB{user.profile.last_seen_on}")
continue
last_notification_date = user_feed_notification.last_notification_date
try:
2022-03-02 10:02:48 -05:00
usersub = UserSubscription.objects.get(
user=user_feed_notification.user_id, feed=user_feed_notification.feed_id
)
except UserSubscription.DoesNotExist:
continue
classifiers = user_feed_notification.classifiers(usersub)
2022-03-02 11:07:20 -05:00
if classifiers is None:
2021-03-03 13:09:24 -05:00
if settings.DEBUG:
logging.debug("Has no usersubs")
continue
for story in stories:
if sent_count >= 3:
2021-03-03 13:09:24 -05:00
if settings.DEBUG:
logging.debug("Sent too many, ignoring...")
2022-03-02 10:02:48 -05:00
continue
2024-04-24 09:43:56 -04:00
if story["story_date"] <= last_notification_date and not force:
2021-03-03 11:16:52 -05:00
if settings.DEBUG:
2022-03-02 10:02:48 -05:00
logging.debug(
"Story date older than last notification date: %s <= %s"
2024-04-24 09:43:56 -04:00
% (story["story_date"], last_notification_date)
2022-03-02 10:02:48 -05:00
)
continue
2022-03-02 10:02:48 -05:00
2024-04-24 09:43:56 -04:00
if story["story_date"] > user_feed_notification.last_notification_date:
user_feed_notification.last_notification_date = story["story_date"]
user_feed_notification.save()
2022-03-02 10:02:48 -05:00
2024-04-24 09:43:56 -04:00
story["story_content"] = html.unescape(story["story_content"])
2022-03-02 10:02:48 -05:00
sent = user_feed_notification.push_story_notification(story, classifiers, usersub)
2022-03-02 10:02:48 -05:00
if sent:
2016-11-21 17:36:21 -08:00
sent_count += 1
total_sent_count += 1
return total_sent_count, len(notifications)
2022-03-02 10:02:48 -05:00
def classifiers(self, usersub):
classifiers = {}
if usersub.is_trained:
2024-04-24 09:43:56 -04:00
classifiers["feeds"] = list(
MClassifierFeed.objects(user_id=self.user_id, feed_id=self.feed_id, social_user_id=0)
2022-03-02 10:02:48 -05:00
)
2024-04-24 09:43:56 -04:00
classifiers["authors"] = list(
2022-03-02 10:02:48 -05:00
MClassifierAuthor.objects(user_id=self.user_id, feed_id=self.feed_id)
)
2024-04-24 09:43:56 -04:00
classifiers["titles"] = list(MClassifierTitle.objects(user_id=self.user_id, feed_id=self.feed_id))
classifiers["tags"] = list(MClassifierTag.objects(user_id=self.user_id, feed_id=self.feed_id))
2022-03-02 10:02:48 -05:00
return classifiers
2022-03-02 10:02:48 -05:00
def title_and_body(self, story, usersub, notification_title_only=False):
def replace_with_newlines(element):
2024-04-24 09:43:56 -04:00
text = ""
for elem in element.recursiveChildGenerator():
if isinstance(elem, (str,)):
text += elem
2024-04-24 09:43:56 -04:00
elif elem.name == "br":
text += "\n"
elif elem.name == "p":
text += "\n\n"
text = re.sub(r" +", " ", text).strip()
return text
2022-03-02 10:02:48 -05:00
feed_title = usersub.user_title or usersub.feed.feed_title
# title = "%s: %s" % (feed_title, story['story_title'])
title = feed_title
2024-04-24 09:43:56 -04:00
soup = BeautifulSoup(story["story_content"].strip(), features="lxml")
# if notification_title_only:
subtitle = None
2024-04-24 09:43:56 -04:00
body_title = html.unescape(story["story_title"]).strip()
body_content = replace_with_newlines(soup)
if body_content:
2024-04-24 09:43:56 -04:00
if body_title == body_content[: len(body_title)] or body_content[:100] == body_title[:100]:
body_content = ""
else:
2022-03-02 10:02:48 -05:00
body_content = f"\n{body_content}"
body = f"{body_title}{body_content}"
# else:
# subtitle = html.unescape(story['story_title'])
# body = replace_with_newlines(soup)
body = truncate_chars(body.strip(), 3600)
if not body:
2017-05-04 17:43:22 -07:00
body = " "
2022-03-02 10:02:48 -05:00
if not usersub.user.profile.is_premium:
body = "Please upgrade to a premium subscription to receive full push notifications."
2022-03-02 10:02:48 -05:00
return title, subtitle, body
2022-03-02 10:02:48 -05:00
def push_story_notification(self, story, classifiers, usersub):
story_score = self.story_score(story, classifiers)
if self.is_focus and story_score <= 0:
if settings.DEBUG:
logging.debug("Is focus, but story is hidden")
return False
elif story_score < 0:
if settings.DEBUG:
logging.debug("Is unread, but story is hidden")
return False
2022-03-02 10:02:48 -05:00
user = User.objects.get(pk=self.user_id)
2022-03-02 10:02:48 -05:00
logging.user(
user,
"~FCSending push notification: %s/%s (score: %s)"
2024-04-24 09:43:56 -04:00
% (story["story_title"][:40], story["story_hash"], story_score),
2022-03-02 10:02:48 -05:00
)
self.send_web(story, user)
self.send_ios(story, user, usersub)
self.send_android(story)
self.send_email(story, usersub)
2022-03-02 10:02:48 -05:00
return True
2022-03-02 10:02:48 -05:00
def send_web(self, story, user):
2022-03-02 10:02:48 -05:00
if not self.is_web:
return
r = redis.Redis(connection_pool=settings.REDIS_PUBSUB_POOL)
2024-04-24 09:43:56 -04:00
r.publish(user.username, "notification:%s,%s" % (story["story_hash"], story["story_title"]))
2022-03-02 10:02:48 -05:00
def send_ios(self, story, user, usersub):
2022-03-02 10:02:48 -05:00
if not self.is_ios:
return
tokens = MUserNotificationTokens.get_tokens_for_user(self.user_id)
# To update APNS:
2024-03-28 09:59:01 -07:00
# 0. Upgrade to latest openssl: brew install openssl
2023-03-20 10:08:45 -04:00
# 1. Create certificate signing request in Keychain Access
# 2. Upload to https://developer.apple.com/account/resources/certificates/list
# 3. Download to secrets/certificates/ios/aps.cer
2024-03-28 09:59:01 -07:00
# 4. Open in Keychain Access, Under "My Certificates":
2023-03-20 10:08:45 -04:00
# - export "Apple Push Service: com.newsblur.NewsBlur" as aps.p12 (or just use aps.cer in #5)
# - export private key as aps_key.p12 WITH A PASSPHRASE (removed later)
# 5. openssl x509 -in aps.cer -inform DER -out aps.pem -outform PEM
2024-03-28 09:59:01 -07:00
# 6. openssl pkcs12 -in aps_key.p12 -out aps_key.pem -nodes -legacy
2023-03-20 10:08:45 -04:00
# 7. openssl rsa -out aps_key.noenc.pem -in aps_key.pem
# 7. cat aps.pem aps_key.noenc.pem > aps.p12.pem
# 8. Verify: openssl s_client -connect gateway.push.apple.com:2195 -cert aps.p12.pem
# 9. Deploy: aps -l work -t apns,repo,celery
2024-04-24 09:43:56 -04:00
apns = APNsClient("/srv/newsblur/config/certificates/aps.p12.pem", use_sandbox=tokens.use_sandbox)
2022-03-02 10:02:48 -05:00
2024-04-24 09:43:56 -04:00
notification_title_only = is_true(user.profile.preference_value("notification_title_only"))
title, subtitle, body = self.title_and_body(story, usersub, notification_title_only)
image_url = None
2024-04-24 09:43:56 -04:00
if len(story["image_urls"]):
image_url = story["image_urls"][0]
# print image_url
2022-03-02 10:02:48 -05:00
confirmed_ios_tokens = []
for token in tokens.ios_tokens:
2022-03-02 10:02:48 -05:00
logging.user(
user,
2024-04-24 09:43:56 -04:00
"~BMStory notification by iOS: ~FY~SB%s~SN~BM~FY/~SB%s"
% (story["story_title"][:50], usersub.feed.feed_title[:50]),
2022-03-02 10:02:48 -05:00
)
payload = Payload(
2024-04-24 09:43:56 -04:00
alert={"title": title, "subtitle": subtitle, "body": body},
2022-03-02 10:02:48 -05:00
category="STORY_CATEGORY",
mutable_content=True,
custom={
2024-04-24 09:43:56 -04:00
"story_hash": story["story_hash"],
"story_feed_id": story["story_feed_id"],
"image_url": image_url,
2022-03-02 10:02:48 -05:00
},
)
try:
apns.send_notification(token, payload, topic="com.newsblur.NewsBlur")
2024-02-18 13:09:43 -05:00
except (BadDeviceToken, Unregistered, DeviceTokenNotForTopic):
2024-04-24 09:43:56 -04:00
logging.user(user, "~BMiOS token expired: ~FR~SB%s" % (token[:50]))
else:
confirmed_ios_tokens.append(token)
if settings.DEBUG:
2022-03-02 10:02:48 -05:00
logging.user(
user,
2024-04-24 09:43:56 -04:00
"~BMiOS token good: ~FB~SB%s / %s" % (token[:50], len(confirmed_ios_tokens)),
2022-03-02 10:02:48 -05:00
)
if len(confirmed_ios_tokens) < len(tokens.ios_tokens):
tokens.ios_tokens = confirmed_ios_tokens
tokens.save()
2022-03-02 10:02:48 -05:00
def send_android(self, story):
2022-03-02 10:02:48 -05:00
if not self.is_android:
return
def send_email(self, story, usersub):
2022-03-02 10:02:48 -05:00
if not self.is_email:
return
# Increment the daily email counter for this user
r = redis.Redis(connection_pool=settings.REDIS_STATISTICS_POOL)
emails_sent_date_key = f"emails_sent:{datetime.datetime.now().strftime('%Y%m%d')}"
r.hincrby(emails_sent_date_key, usersub.user_id, 1)
r.expire(emails_sent_date_key, 60 * 60 * 24) # Keep for a day
count = int(r.hget(emails_sent_date_key, usersub.user_id) or 0)
if count > settings.MAX_EMAILS_SENT_PER_DAY_PER_USER:
2024-04-24 09:43:56 -04:00
logging.user(
usersub.user,
"~BMSent too many email Story notifications by email: ~FR~SB%s~SN~FR emails" % (count),
)
return
feed = usersub.feed
2024-04-24 09:43:56 -04:00
story_content = self.sanitize_story(story["story_content"])
2022-03-02 10:02:48 -05:00
params = {
"story": story,
"story_content": story_content,
"feed": feed,
"feed_title": usersub.user_title or feed.feed_title,
"favicon_border": feed.favicon_color,
}
2024-04-24 09:43:56 -04:00
from_address = "notifications@newsblur.com"
to_address = "%s <%s>" % (usersub.user.username, usersub.user.email)
text = render_to_string("mail/email_story_notification.txt", params)
html = render_to_string("mail/email_story_notification.xhtml", params)
subject = "%s: %s" % (usersub.user_title or usersub.feed.feed_title, story["story_title"])
subject = subject.replace("\n", " ")
2022-03-02 10:02:48 -05:00
msg = EmailMultiAlternatives(
2024-04-24 09:43:56 -04:00
subject, text, from_email="NewsBlur <%s>" % from_address, to=[to_address]
2022-03-02 10:02:48 -05:00
)
msg.attach_alternative(html, "text/html")
# try:
msg.send()
# except BotoServerError as e:
# logging.user(usersub.user, '~BMStory notification by email error: ~FR%s' % e)
# return
2022-03-02 10:02:48 -05:00
logging.user(
usersub.user,
2024-04-24 09:43:56 -04:00
"~BMStory notification by email: ~FY~SB%s~SN~BM~FY/~SB%s"
% (story["story_title"][:50], usersub.feed.feed_title[:50]),
2022-03-02 10:02:48 -05:00
)
def sanitize_story(self, story_content):
2020-06-30 17:22:47 -04:00
soup = BeautifulSoup(story_content.strip(), features="lxml")
fqdn = Site.objects.get_current().domain
2022-03-02 10:02:48 -05:00
# Convert videos in newsletters to images
for iframe in soup("iframe"):
2024-04-24 09:43:56 -04:00
url = dict(iframe.attrs).get("src", "")
youtube_id = self.extract_youtube_id(url)
if youtube_id:
2024-04-24 09:43:56 -04:00
a = soup.new_tag("a", href=url)
2022-03-02 10:02:48 -05:00
img = soup.new_tag(
2024-04-24 09:43:56 -04:00
"img",
2022-03-02 10:02:48 -05:00
style="display: block; 'background-image': \"url(https://%s/img/reader/youtube_play.png), url(http://img.youtube.com/vi/%s/0.jpg)\""
% (fqdn, youtube_id),
2024-04-24 09:43:56 -04:00
src="http://img.youtube.com/vi/%s/0.jpg" % youtube_id,
2022-03-02 10:02:48 -05:00
)
a.insert(0, img)
iframe.replaceWith(a)
else:
iframe.extract()
2022-03-02 10:02:48 -05:00
return str(soup)
2022-03-02 10:02:48 -05:00
def extract_youtube_id(self, url):
youtube_id = None
2024-04-24 09:43:56 -04:00
if "youtube.com" in url:
youtube_parts = urllib.parse.urlparse(url)
2024-04-24 09:43:56 -04:00
if "/embed/" in youtube_parts.path:
youtube_id = youtube_parts.path.replace("/embed/", "")
2022-03-02 10:02:48 -05:00
return youtube_id
2022-03-02 10:02:48 -05:00
def story_score(self, story, classifiers):
2022-03-02 10:02:48 -05:00
score = compute_story_score(
story,
2024-04-24 09:43:56 -04:00
classifier_titles=classifiers.get("titles", []),
classifier_authors=classifiers.get("authors", []),
classifier_tags=classifiers.get("tags", []),
classifier_feeds=classifiers.get("feeds", []),
2022-03-02 10:02:48 -05:00
)
return score