zulip/zerver/lib/push_notifications.py

365 lines
14 KiB
Python
Raw Normal View History

from __future__ import absolute_import
import random
import requests
from typing import Any, Dict, List, Optional, SupportsInt, Text, Union, Type
from version import ZULIP_VERSION
from zerver.models import PushDeviceToken, Message, Recipient, UserProfile, \
UserMessage, get_display_recipient, receives_offline_notifications, \
receives_online_notifications
2016-08-03 12:47:12 +02:00
from zerver.models import get_user_profile_by_id
from zerver.lib.avatar import absolute_avatar_url
from zerver.lib.request import JsonableError
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.decorator import statsd_increment
from zerver.lib.utils import generate_random_token
from zerver.lib.queue import retry_event
from gcm import GCM
from django.conf import settings
from django.utils.timezone import now as timezone_now
from django.utils.translation import ugettext as _
from six.moves import urllib
2017-01-24 06:36:39 +01:00
import base64
import binascii
import logging
import os
import time
import ujson
from functools import partial
if settings.ZILENCER_ENABLED:
from zilencer.models import RemotePushDeviceToken
else: # nocoverage -- Not convenient to add test for this.
from mock import Mock
RemotePushDeviceToken = Mock() # type: ignore # https://github.com/JukkaL/mypy/issues/1188
DeviceToken = Union[PushDeviceToken, RemotePushDeviceToken]
# `APNS_SANDBOX` should be a bool
assert isinstance(settings.APNS_SANDBOX, bool)
def uses_notification_bouncer():
# type: () -> bool
return settings.PUSH_NOTIFICATION_BOUNCER_URL is not None
def num_push_devices_for_user(user_profile, kind = None):
# type: (UserProfile, Optional[int]) -> PushDeviceToken
if kind is None:
return PushDeviceToken.objects.filter(user=user_profile).count()
else:
return PushDeviceToken.objects.filter(user=user_profile, kind=kind).count()
# We store the token as b64, but apns-client wants hex strings
def b64_to_hex(data):
# type: (bytes) -> Text
return binascii.hexlify(base64.b64decode(data)).decode('utf-8')
def hex_to_b64(data):
# type: (Text) -> bytes
return base64.b64encode(binascii.unhexlify(data.encode('utf-8')))
@statsd_increment("apple_push_notification")
def send_apple_push_notification(user_id, devices, **extra_data):
# type: (int, List[DeviceToken], **Any) -> None
if not devices:
return
logging.warn("APNs unimplemented. Dropping notification for user %d with %d devices.",
user_id, len(devices))
if settings.ANDROID_GCM_API_KEY: # nocoverage
gcm = GCM(settings.ANDROID_GCM_API_KEY)
else:
gcm = None
def send_android_push_notification_to_user(user_profile, data):
# type: (UserProfile, Dict[str, Any]) -> None
devices = list(PushDeviceToken.objects.filter(user=user_profile,
kind=PushDeviceToken.GCM))
send_android_push_notification(devices, data)
@statsd_increment("android_push_notification")
def send_android_push_notification(devices, data, remote=False):
# type: (List[DeviceToken], Dict[str, Any], bool) -> None
if not gcm:
logging.warning("Attempting to send a GCM push notification, but no API key was configured")
return
reg_ids = [device.token for device in devices]
if remote:
DeviceTokenClass = RemotePushDeviceToken
else:
DeviceTokenClass = PushDeviceToken
try:
res = gcm.json_request(registration_ids=reg_ids, data=data, retries=10)
except IOError as e:
logging.warning(str(e))
return
if res and 'success' in res:
for reg_id, msg_id in res['success'].items():
logging.info("GCM: Sent %s as %s" % (reg_id, msg_id))
# res.canonical will contain results when there are duplicate registrations for the same
# device. The "canonical" registration is the latest registration made by the device.
# Ref: http://developer.android.com/google/gcm/adv.html#canonical
if 'canonical' in res:
for reg_id, new_reg_id in res['canonical'].items():
if reg_id == new_reg_id:
# I'm not sure if this should happen. In any case, not really actionable.
logging.warning("GCM: Got canonical ref but it already matches our ID %s!" % (reg_id,))
elif not DeviceTokenClass.objects.filter(token=new_reg_id,
kind=DeviceTokenClass.GCM).count():
# This case shouldn't happen; any time we get a canonical ref it should have been
# previously registered in our system.
#
# That said, recovery is easy: just update the current PDT object to use the new ID.
logging.warning(
2017-01-24 07:06:13 +01:00
"GCM: Got canonical ref %s replacing %s but new ID not registered! Updating." %
(new_reg_id, reg_id))
DeviceTokenClass.objects.filter(
token=reg_id, kind=DeviceTokenClass.GCM).update(token=new_reg_id)
else:
# Since we know the new ID is registered in our system we can just drop the old one.
logging.info("GCM: Got canonical ref %s, dropping %s" % (new_reg_id, reg_id))
DeviceTokenClass.objects.filter(token=reg_id, kind=DeviceTokenClass.GCM).delete()
if 'errors' in res:
for error, reg_ids in res['errors'].items():
if error in ['NotRegistered', 'InvalidRegistration']:
for reg_id in reg_ids:
logging.info("GCM: Removing %s" % (reg_id,))
device = DeviceTokenClass.objects.get(token=reg_id, kind=DeviceTokenClass.GCM)
device.delete()
else:
for reg_id in reg_ids:
logging.warning("GCM: Delivery to %s failed: %s" % (reg_id, error))
# python-gcm handles retrying of the unsent messages.
# Ref: https://github.com/geeknam/python-gcm/blob/master/gcm/gcm.py#L497
def get_alert_from_message(message):
# type: (Message) -> Text
"""
Determine what alert string to display based on the missed messages.
"""
sender_str = message.sender.full_name
if message.recipient.type == Recipient.HUDDLE:
return "New private group message from %s" % (sender_str,)
elif message.recipient.type == Recipient.PERSONAL:
return "New private message from %s" % (sender_str,)
elif message.recipient.type == Recipient.STREAM:
return "New mention from %s" % (sender_str,)
else:
return "New Zulip mentions and private messages from %s" % (sender_str,)
def get_apns_payload(message):
# type: (Message) -> Dict[str, Any]
return {
'alert': get_alert_from_message(message),
'message_ids': [message.id],
}
def get_gcm_payload(user_profile, message):
# type: (UserProfile, Message) -> Dict[str, Any]
content = message.content
content_truncated = (len(content) > 200)
if content_truncated:
content = content[:200] + "..."
android_data = {
'user': user_profile.email,
'event': 'message',
'alert': get_alert_from_message(message),
'zulip_message_id': message.id, # message_id is reserved for CCS
'time': datetime_to_timestamp(message.pub_date),
'content': content,
'content_truncated': content_truncated,
'sender_email': message.sender.email,
'sender_full_name': message.sender.full_name,
'sender_avatar_url': absolute_avatar_url(message.sender),
}
if message.recipient.type == Recipient.STREAM:
android_data['recipient_type'] = "stream"
android_data['stream'] = get_display_recipient(message.recipient)
android_data['topic'] = message.subject
elif message.recipient.type in (Recipient.HUDDLE, Recipient.PERSONAL):
android_data['recipient_type'] = "private"
return android_data
@statsd_increment("push_notifications")
def handle_push_notification(user_profile_id, missed_message):
# type: (int, Dict[str, Any]) -> None
"""
missed_message is the event received by the
zerver.worker.queue_processors.PushNotificationWorker.consume function.
"""
try:
user_profile = get_user_profile_by_id(user_profile_id)
if not (receives_offline_notifications(user_profile) or receives_online_notifications(user_profile)):
return
umessage = UserMessage.objects.get(user_profile=user_profile,
message__id=missed_message['message_id'])
message = umessage.message
if umessage.flags.read:
return
apns_payload = get_apns_payload(message)
gcm_payload = get_gcm_payload(user_profile, message)
if uses_notification_bouncer():
try:
send_notifications_to_bouncer(user_profile_id,
apns_payload,
gcm_payload)
except requests.ConnectionError:
if 'failed_tries' not in missed_message:
missed_message['failed_tries'] = 0
def failure_processor(event):
# type: (Dict[str, Any]) -> None
logging.warning("Maximum retries exceeded for trigger:%s event:push_notification" % (event['user_profile_id']))
retry_event('missedmessage_mobile_notifications', missed_message,
failure_processor)
return
android_devices = list(PushDeviceToken.objects.filter(user=user_profile,
kind=PushDeviceToken.GCM))
apple_devices = list(PushDeviceToken.objects.filter(user=user_profile,
kind=PushDeviceToken.APNS))
# TODO: set badge count in a better way
if apple_devices:
send_apple_push_notification(user_profile.id, apple_devices,
badge=1, zulip=apns_payload)
if android_devices:
send_android_push_notification(android_devices, gcm_payload)
except UserMessage.DoesNotExist:
logging.error("Could not find UserMessage with message_id %s" % (missed_message['message_id'],))
def send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload):
# type: (int, Dict[str, Any], Dict[str, Any]) -> None
post_data = {
'user_id': user_profile_id,
'apns_payload': apns_payload,
'gcm_payload': gcm_payload,
}
send_json_to_push_bouncer('POST', 'notify', post_data)
def add_push_device_token(user_profile, token_str, kind, ios_app_id=None):
# type: (UserProfile, bytes, int, Optional[str]) -> None
logging.info("New push device: %d %r %d %r",
user_profile.id, token_str, kind, ios_app_id)
# If we're sending things to the push notification bouncer
# register this user with them here
if uses_notification_bouncer():
post_data = {
'server_uuid': settings.ZULIP_ORG_ID,
'user_id': user_profile.id,
'token': token_str,
'token_kind': kind,
}
if kind == PushDeviceToken.APNS:
post_data['ios_app_id'] = ios_app_id
logging.info("Sending new push device to bouncer: %r", post_data)
send_to_push_bouncer('POST', 'register', post_data)
return
# If another user was previously logged in on the same device and didn't
# properly log out, the token will still be registered to the wrong account
PushDeviceToken.objects.filter(token=token_str).exclude(user=user_profile).delete()
# Overwrite with the latest value
token, created = PushDeviceToken.objects.get_or_create(user=user_profile,
token=token_str,
defaults=dict(
kind=kind,
ios_app_id=ios_app_id))
if not created:
logging.info("Existing push device updated.")
token.last_updated = timezone_now()
token.save(update_fields=['last_updated'])
else:
logging.info("New push device created.")
def remove_push_device_token(user_profile, token_str, kind):
# type: (UserProfile, bytes, int) -> None
# If we're sending things to the push notification bouncer
# register this user with them here
if uses_notification_bouncer():
# TODO: Make this a remove item
post_data = {
'server_uuid': settings.ZULIP_ORG_ID,
'user_id': user_profile.id,
'token': token_str,
'token_kind': kind,
}
send_to_push_bouncer("POST", "unregister", post_data)
return
try:
token = PushDeviceToken.objects.get(token=token_str, kind=kind)
token.delete()
except PushDeviceToken.DoesNotExist:
raise JsonableError(_("Token does not exist"))
def send_json_to_push_bouncer(method, endpoint, post_data):
# type: (str, str, Dict[str, Any]) -> None
send_to_push_bouncer(
method,
endpoint,
ujson.dumps(post_data),
extra_headers={"Content-type": "application/json"},
)
def send_to_push_bouncer(method, endpoint, post_data, extra_headers=None):
# type: (str, str, Union[Text, Dict[str, Any]], Optional[Dict[str, Any]]) -> None
url = urllib.parse.urljoin(settings.PUSH_NOTIFICATION_BOUNCER_URL,
'/api/v1/remotes/push/' + endpoint)
api_auth = requests.auth.HTTPBasicAuth(settings.ZULIP_ORG_ID,
settings.ZULIP_ORG_KEY)
headers = {"User-agent": "ZulipServer/%s" % (ZULIP_VERSION,)}
if extra_headers is not None:
headers.update(extra_headers)
res = requests.request(method,
url,
data=post_data,
auth=api_auth,
timeout=30,
verify=True,
headers=headers)
# TODO: Think more carefully about how this error hanlding should work.
if res.status_code >= 500:
raise JsonableError(_("Error received from push notification bouncer"))
elif res.status_code >= 400:
try:
msg = ujson.loads(res.content)['msg']
except Exception:
raise JsonableError(_("Error received from push notification bouncer"))
raise JsonableError(msg)
elif res.status_code != 200:
raise JsonableError(_("Error received from push notification bouncer"))
# If we don't throw an exception, it's a successful bounce!