2017-10-07 00:12:52 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
2016-06-15 08:28:52 +02:00
|
|
|
|
2017-08-19 01:12:40 +02:00
|
|
|
import base64
|
|
|
|
import binascii
|
|
|
|
from functools import partial
|
|
|
|
import logging
|
2017-10-07 00:18:07 +02:00
|
|
|
import lxml.html as LH
|
2017-08-19 01:12:40 +02:00
|
|
|
import os
|
2017-10-07 00:18:07 +02:00
|
|
|
import re
|
2017-08-19 01:12:40 +02:00
|
|
|
import time
|
2016-08-03 12:20:12 +02:00
|
|
|
import random
|
2017-10-07 00:18:07 +02:00
|
|
|
|
2018-10-04 23:31:04 +02:00
|
|
|
from typing import Any, Dict, List, Optional, SupportsInt, Tuple, Type, Union, cast
|
2013-10-21 19:47:08 +02:00
|
|
|
|
|
|
|
from django.conf import settings
|
2018-10-11 00:53:13 +02:00
|
|
|
from django.db import IntegrityError, transaction
|
2017-04-15 04:03:56 +02:00
|
|
|
from django.utils.timezone import now as timezone_now
|
2017-03-06 03:57:31 +01:00
|
|
|
from django.utils.translation import ugettext as _
|
2017-08-19 01:12:40 +02:00
|
|
|
from gcm import GCM
|
|
|
|
import requests
|
2017-11-05 05:30:31 +01:00
|
|
|
import urllib
|
2016-10-27 23:55:31 +02:00
|
|
|
import ujson
|
2017-08-19 01:12:40 +02:00
|
|
|
|
|
|
|
from zerver.decorator import statsd_increment
|
|
|
|
from zerver.lib.avatar import absolute_avatar_url
|
2017-10-12 03:02:35 +02:00
|
|
|
from zerver.lib.exceptions import ErrorCode, JsonableError
|
2018-02-16 23:18:47 +01:00
|
|
|
from zerver.lib.message import access_message, huddle_users
|
2017-08-19 01:12:40 +02:00
|
|
|
from zerver.lib.queue import retry_event
|
|
|
|
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
|
|
|
from zerver.lib.utils import generate_random_token
|
|
|
|
from zerver.models import PushDeviceToken, Message, Recipient, UserProfile, \
|
2017-11-29 02:49:11 +01:00
|
|
|
UserMessage, get_display_recipient, receives_offline_push_notifications, \
|
2017-08-17 16:55:32 +02:00
|
|
|
receives_online_notifications, receives_stream_notifications, get_user_profile_by_id
|
2017-08-19 01:12:40 +02:00
|
|
|
from version import ZULIP_VERSION
|
2016-06-08 12:32:59 +02:00
|
|
|
|
2017-05-09 12:15:11 +02:00
|
|
|
if settings.ZILENCER_ENABLED:
|
|
|
|
from zilencer.models import RemotePushDeviceToken
|
2017-05-12 10:12:38 +02:00
|
|
|
else: # nocoverage -- Not convenient to add test for this.
|
2017-05-09 12:15:11 +02:00
|
|
|
from mock import Mock
|
|
|
|
RemotePushDeviceToken = Mock() # type: ignore # https://github.com/JukkaL/mypy/issues/1188
|
|
|
|
|
|
|
|
DeviceToken = Union[PushDeviceToken, RemotePushDeviceToken]
|
|
|
|
|
2013-10-21 19:47:08 +02:00
|
|
|
# We store the token as b64, but apns-client wants hex strings
|
2018-05-11 01:40:23 +02:00
|
|
|
def b64_to_hex(data: bytes) -> str:
|
2016-06-15 08:28:52 +02:00
|
|
|
return binascii.hexlify(base64.b64decode(data)).decode('utf-8')
|
2013-10-21 19:47:08 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def hex_to_b64(data: str) -> bytes:
|
2016-06-15 08:28:52 +02:00
|
|
|
return base64.b64encode(binascii.unhexlify(data.encode('utf-8')))
|
2013-10-21 19:47:08 +02:00
|
|
|
|
2017-08-19 00:42:04 +02:00
|
|
|
#
|
|
|
|
# Sending to APNs, for iOS
|
|
|
|
#
|
|
|
|
|
2018-08-08 18:16:57 +02:00
|
|
|
_apns_client = None # type: Optional[Any]
|
2018-02-09 23:19:00 +01:00
|
|
|
_apns_client_initialized = False
|
2017-08-19 01:38:11 +02:00
|
|
|
|
2018-08-08 18:16:57 +02:00
|
|
|
def get_apns_client() -> Any:
|
|
|
|
# We lazily do this import as part of optimizing Zulip's base
|
|
|
|
# import time.
|
|
|
|
from apns2.client import APNsClient
|
2018-02-09 23:19:00 +01:00
|
|
|
global _apns_client, _apns_client_initialized
|
|
|
|
if not _apns_client_initialized:
|
2017-08-19 01:38:11 +02:00
|
|
|
# NB if called concurrently, this will make excess connections.
|
|
|
|
# That's a little sloppy, but harmless unless a server gets
|
|
|
|
# hammered with a ton of these all at once after startup.
|
2018-02-09 23:19:00 +01:00
|
|
|
if settings.APNS_CERT_FILE is not None:
|
|
|
|
_apns_client = APNsClient(credentials=settings.APNS_CERT_FILE,
|
|
|
|
use_sandbox=settings.APNS_SANDBOX)
|
|
|
|
_apns_client_initialized = True
|
2017-08-19 01:38:11 +02:00
|
|
|
return _apns_client
|
2017-08-19 00:42:04 +02:00
|
|
|
|
2018-02-12 23:34:59 +01:00
|
|
|
def apns_enabled() -> bool:
|
|
|
|
client = get_apns_client()
|
|
|
|
return client is not None
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def modernize_apns_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
2017-09-28 03:08:37 +02:00
|
|
|
'''Take a payload in an unknown Zulip version's format, and return in current format.'''
|
|
|
|
# TODO this isn't super robust as is -- if a buggy remote server
|
|
|
|
# sends a malformed payload, we are likely to raise an exception.
|
|
|
|
if 'message_ids' in data:
|
|
|
|
# The format sent by 1.6.0, from the earliest pre-1.6.0
|
|
|
|
# version with bouncer support up until 613d093d7 pre-1.7.0:
|
|
|
|
# 'alert': str, # just sender, and text about PM/group-PM/mention
|
|
|
|
# 'message_ids': List[int], # always just one
|
|
|
|
return {
|
|
|
|
'alert': data['alert'],
|
2017-10-02 09:00:25 +02:00
|
|
|
'badge': 0,
|
2017-09-28 03:08:37 +02:00
|
|
|
'custom': {
|
|
|
|
'zulip': {
|
|
|
|
'message_ids': data['message_ids'],
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
else:
|
2017-12-12 05:54:23 +01:00
|
|
|
# Something already compatible with the current format.
|
|
|
|
# `alert` may be a string, or a dict with `title` and `body`.
|
|
|
|
# In 1.7.0 and 1.7.1, before 0912b5ba8 pre-1.8.0, the only
|
|
|
|
# item in `custom.zulip` is `message_ids`.
|
2017-09-28 03:08:37 +02:00
|
|
|
return data
|
|
|
|
|
2017-08-29 01:05:20 +02:00
|
|
|
APNS_MAX_RETRIES = 3
|
|
|
|
|
2015-02-10 08:08:47 +01:00
|
|
|
@statsd_increment("apple_push_notification")
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_apple_push_notification(user_id: int, devices: List[DeviceToken],
|
2018-05-21 20:39:58 +02:00
|
|
|
payload_data: Dict[str, Any], remote: bool=False) -> None:
|
2018-08-08 18:16:57 +02:00
|
|
|
# We lazily do the APNS imports as part of optimizing Zulip's base
|
|
|
|
# import time; since these are only needed in the push
|
|
|
|
# notification queue worker, it's best to only import them in the
|
|
|
|
# code that needs them.
|
|
|
|
from apns2.payload import Payload as APNsPayload
|
|
|
|
from apns2.client import APNsClient
|
|
|
|
from hyper.http20.exceptions import HTTP20Error
|
|
|
|
|
|
|
|
client = get_apns_client() # type: APNsClient
|
2018-02-09 23:19:00 +01:00
|
|
|
if client is None:
|
|
|
|
logging.warning("APNs: Dropping a notification because nothing configured. "
|
|
|
|
"Set PUSH_NOTIFICATION_BOUNCER_URL (or APNS_CERT_FILE).")
|
|
|
|
return
|
2018-05-21 20:20:23 +02:00
|
|
|
|
|
|
|
if remote:
|
|
|
|
DeviceTokenClass = RemotePushDeviceToken
|
|
|
|
else:
|
|
|
|
DeviceTokenClass = PushDeviceToken
|
|
|
|
|
2017-08-19 01:38:11 +02:00
|
|
|
logging.info("APNs: Sending notification for user %d to %d devices",
|
2017-08-19 00:09:26 +02:00
|
|
|
user_id, len(devices))
|
2017-09-28 03:08:37 +02:00
|
|
|
payload = APNsPayload(**modernize_apns_payload(payload_data))
|
2017-08-19 01:38:11 +02:00
|
|
|
expiration = int(time.time() + 24 * 3600)
|
2017-08-29 01:05:20 +02:00
|
|
|
retries_left = APNS_MAX_RETRIES
|
2017-08-19 01:38:11 +02:00
|
|
|
for device in devices:
|
|
|
|
# TODO obviously this should be made to actually use the async
|
2017-08-29 01:05:20 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def attempt_send() -> Optional[str]:
|
2017-08-29 01:05:20 +02:00
|
|
|
stream_id = client.send_notification_async(
|
|
|
|
device.token, payload, topic='org.zulip.Zulip',
|
|
|
|
expiration=expiration)
|
|
|
|
try:
|
|
|
|
return client.get_notification_result(stream_id)
|
|
|
|
except HTTP20Error as e:
|
2017-10-02 11:11:42 +02:00
|
|
|
logging.warning("APNs: HTTP error sending for user %d to device %s: %s",
|
|
|
|
user_id, device.token, e.__class__.__name__)
|
2017-08-29 01:05:20 +02:00
|
|
|
return None
|
|
|
|
|
|
|
|
result = attempt_send()
|
|
|
|
while result is None and retries_left > 0:
|
|
|
|
retries_left -= 1
|
|
|
|
result = attempt_send()
|
|
|
|
if result is None:
|
|
|
|
result = "HTTP error, retries exhausted"
|
|
|
|
|
2018-08-28 21:11:49 +02:00
|
|
|
if result[0] == "Unregistered":
|
|
|
|
# For some reason, "Unregistered" result values have a
|
|
|
|
# different format, as a tuple of the pair ("Unregistered", 12345132131).
|
|
|
|
result = result[0] # type: ignore # APNS API is inconsistent
|
2017-08-19 01:38:11 +02:00
|
|
|
if result == 'Success':
|
|
|
|
logging.info("APNs: Success sending for user %d to device %s",
|
|
|
|
user_id, device.token)
|
2018-05-21 20:20:23 +02:00
|
|
|
elif result in ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"]:
|
|
|
|
logging.info("APNs: Removing invalid/expired token %s (%s)" % (device.token, result))
|
|
|
|
# We remove all entries for this token (There
|
|
|
|
# could be multiple for different Zulip servers).
|
|
|
|
DeviceTokenClass.objects.filter(token=device.token, kind=DeviceTokenClass.APNS).delete()
|
2017-08-19 01:38:11 +02:00
|
|
|
else:
|
2017-10-02 11:11:42 +02:00
|
|
|
logging.warning("APNs: Failed to send for user %d to device %s: %s",
|
|
|
|
user_id, device.token, result)
|
2013-12-09 23:19:59 +01:00
|
|
|
|
2017-08-19 00:42:04 +02:00
|
|
|
#
|
|
|
|
# Sending to GCM, for Android
|
|
|
|
#
|
|
|
|
|
2017-05-12 10:12:38 +02:00
|
|
|
if settings.ANDROID_GCM_API_KEY: # nocoverage
|
2016-12-13 08:41:48 +01:00
|
|
|
gcm = GCM(settings.ANDROID_GCM_API_KEY)
|
2013-12-09 23:19:59 +01:00
|
|
|
else:
|
|
|
|
gcm = None
|
|
|
|
|
2018-02-12 23:34:59 +01:00
|
|
|
def gcm_enabled() -> bool: # nocoverage
|
|
|
|
return gcm is not None
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_android_push_notification_to_user(user_profile: UserProfile, data: Dict[str, Any]) -> None:
|
2017-03-06 03:11:44 +01:00
|
|
|
devices = list(PushDeviceToken.objects.filter(user=user_profile,
|
|
|
|
kind=PushDeviceToken.GCM))
|
|
|
|
send_android_push_notification(devices, data)
|
|
|
|
|
|
|
|
@statsd_increment("android_push_notification")
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_android_push_notification(devices: List[DeviceToken], data: Dict[str, Any],
|
|
|
|
remote: bool=False) -> None:
|
2013-12-09 23:19:59 +01:00
|
|
|
if not gcm:
|
2017-10-14 02:21:38 +02:00
|
|
|
logging.warning("Skipping sending a GCM push notification since "
|
|
|
|
"PUSH_NOTIFICATION_BOUNCER_URL and ANDROID_GCM_API_KEY are both unset")
|
2013-12-09 23:19:59 +01:00
|
|
|
return
|
2017-03-06 03:11:44 +01:00
|
|
|
reg_ids = [device.token for device in devices]
|
2013-12-09 23:19:59 +01:00
|
|
|
|
2017-05-16 21:15:45 +02:00
|
|
|
if remote:
|
2017-05-17 07:16:20 +02:00
|
|
|
DeviceTokenClass = RemotePushDeviceToken
|
2017-05-16 21:15:45 +02:00
|
|
|
else:
|
|
|
|
DeviceTokenClass = PushDeviceToken
|
2017-05-09 12:15:11 +02:00
|
|
|
|
2017-05-17 09:58:27 +02:00
|
|
|
try:
|
2017-05-17 10:00:23 +02:00
|
|
|
res = gcm.json_request(registration_ids=reg_ids, data=data, retries=10)
|
2017-05-17 09:58:27 +02:00
|
|
|
except IOError as e:
|
|
|
|
logging.warning(str(e))
|
|
|
|
return
|
2013-12-09 23:19:59 +01:00
|
|
|
|
2016-12-13 08:41:48 +01:00
|
|
|
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))
|
2013-12-09 23:19:59 +01:00
|
|
|
|
2014-01-22 22:42:23 +01:00
|
|
|
# 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
|
2016-12-13 08:41:48 +01:00
|
|
|
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,))
|
2017-05-09 12:15:11 +02:00
|
|
|
elif not DeviceTokenClass.objects.filter(token=new_reg_id,
|
|
|
|
kind=DeviceTokenClass.GCM).count():
|
2016-12-13 08:41:48 +01:00
|
|
|
# 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))
|
2017-05-09 12:15:11 +02:00
|
|
|
DeviceTokenClass.objects.filter(
|
|
|
|
token=reg_id, kind=DeviceTokenClass.GCM).update(token=new_reg_id)
|
2016-12-13 08:41:48 +01:00
|
|
|
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))
|
|
|
|
|
2017-05-09 12:15:11 +02:00
|
|
|
DeviceTokenClass.objects.filter(token=reg_id, kind=DeviceTokenClass.GCM).delete()
|
2016-12-13 08:41:48 +01:00
|
|
|
|
|
|
|
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,))
|
2018-03-23 18:23:23 +01:00
|
|
|
# We remove all entries for this token (There
|
|
|
|
# could be multiple for different Zulip servers).
|
|
|
|
DeviceTokenClass.objects.filter(token=reg_id, kind=DeviceTokenClass.GCM).delete()
|
2016-12-13 08:41:48 +01:00
|
|
|
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
|
2017-03-06 03:05:04 +01:00
|
|
|
|
2017-08-19 00:42:04 +02:00
|
|
|
#
|
|
|
|
# Sending to a bouncer
|
|
|
|
#
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def uses_notification_bouncer() -> bool:
|
2017-08-19 00:42:04 +02:00
|
|
|
return settings.PUSH_NOTIFICATION_BOUNCER_URL is not None
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_notifications_to_bouncer(user_profile_id: int,
|
|
|
|
apns_payload: Dict[str, Any],
|
|
|
|
gcm_payload: Dict[str, Any]) -> None:
|
2017-08-19 00:42:04 +02:00
|
|
|
post_data = {
|
|
|
|
'user_id': user_profile_id,
|
|
|
|
'apns_payload': apns_payload,
|
|
|
|
'gcm_payload': gcm_payload,
|
|
|
|
}
|
2018-01-17 08:10:45 +01:00
|
|
|
# Calls zilencer.views.remote_server_notify_push
|
2017-08-19 00:42:04 +02:00
|
|
|
send_json_to_push_bouncer('POST', 'notify', post_data)
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_json_to_push_bouncer(method: str, endpoint: str, post_data: Dict[str, Any]) -> None:
|
2017-08-19 00:42:04 +02:00
|
|
|
send_to_push_bouncer(
|
|
|
|
method,
|
|
|
|
endpoint,
|
|
|
|
ujson.dumps(post_data),
|
|
|
|
extra_headers={"Content-type": "application/json"},
|
|
|
|
)
|
|
|
|
|
2017-10-12 03:02:35 +02:00
|
|
|
class PushNotificationBouncerException(Exception):
|
|
|
|
pass
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_to_push_bouncer(method: str,
|
|
|
|
endpoint: str,
|
2018-05-11 01:40:23 +02:00
|
|
|
post_data: Union[str, Dict[str, Any]],
|
2017-11-05 11:15:10 +01:00
|
|
|
extra_headers: Optional[Dict[str, Any]]=None) -> None:
|
2017-10-12 03:02:35 +02:00
|
|
|
"""While it does actually send the notice, this function has a lot of
|
|
|
|
code and comments around error handling for the push notifications
|
|
|
|
bouncer. There are several classes of failures, each with its own
|
|
|
|
potential solution:
|
|
|
|
|
|
|
|
* Network errors with requests.request. We let those happen normally.
|
|
|
|
|
|
|
|
* 500 errors from the push bouncer or other unexpected responses;
|
|
|
|
we don't try to parse the response, but do make clear the cause.
|
|
|
|
|
|
|
|
* 400 errors from the push bouncer. Here there are 2 categories:
|
|
|
|
Our server failed to connect to the push bouncer (should throw)
|
|
|
|
vs. client-side errors like and invalid token.
|
|
|
|
|
|
|
|
"""
|
2017-08-19 00:42:04 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
if res.status_code >= 500:
|
2017-10-12 03:02:35 +02:00
|
|
|
# 500s should be resolved by the people who run the push
|
|
|
|
# notification bouncer service, since they'll get an email
|
|
|
|
# too. For now we email the server admin, but we'll likely
|
|
|
|
# want to do some sort of retry logic eventually.
|
|
|
|
raise PushNotificationBouncerException(
|
|
|
|
_("Received 500 from push notification bouncer"))
|
2017-08-19 00:42:04 +02:00
|
|
|
elif res.status_code >= 400:
|
2017-10-12 03:02:35 +02:00
|
|
|
# If JSON parsing errors, just let that exception happen
|
|
|
|
result_dict = ujson.loads(res.content)
|
|
|
|
msg = result_dict['msg']
|
|
|
|
if 'code' in result_dict and result_dict['code'] == 'INVALID_ZULIP_SERVER':
|
|
|
|
# Invalid Zulip server credentials should email this server's admins
|
|
|
|
raise PushNotificationBouncerException(
|
|
|
|
_("Push notifications bouncer error: %s") % (msg,))
|
|
|
|
else:
|
|
|
|
# But most other errors coming from the push bouncer
|
|
|
|
# server are client errors (e.g. never-registered token)
|
|
|
|
# and should be handled as such.
|
|
|
|
raise JsonableError(msg)
|
2017-08-19 00:42:04 +02:00
|
|
|
elif res.status_code != 200:
|
2017-10-12 03:02:35 +02:00
|
|
|
# Anything else is unexpected and likely suggests a bug in
|
|
|
|
# this version of Zulip, so we throw an exception that will
|
|
|
|
# email the server admins.
|
|
|
|
raise PushNotificationBouncerException(
|
|
|
|
"Push notification bouncer returned unexpected status code %s" % (res.status_code,))
|
2017-08-19 00:42:04 +02:00
|
|
|
|
|
|
|
# If we don't throw an exception, it's a successful bounce!
|
|
|
|
|
|
|
|
#
|
|
|
|
# Managing device tokens
|
|
|
|
#
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def num_push_devices_for_user(user_profile: UserProfile, kind: Optional[int]=None) -> PushDeviceToken:
|
2017-08-19 00:42:04 +02:00
|
|
|
if kind is None:
|
|
|
|
return PushDeviceToken.objects.filter(user=user_profile).count()
|
|
|
|
else:
|
|
|
|
return PushDeviceToken.objects.filter(user=user_profile, kind=kind).count()
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def add_push_device_token(user_profile: UserProfile,
|
|
|
|
token_str: bytes,
|
|
|
|
kind: int,
|
|
|
|
ios_app_id: Optional[str]=None) -> None:
|
2018-10-11 00:53:13 +02:00
|
|
|
logging.info("Registering push device: %d %r %d %r",
|
2017-08-19 00:42:04 +02:00
|
|
|
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)
|
2018-04-29 00:06:26 +02:00
|
|
|
# Calls zilencer.views.register_remote_push_device
|
2017-08-19 00:42:04 +02:00
|
|
|
send_to_push_bouncer('POST', 'register', post_data)
|
|
|
|
return
|
|
|
|
|
2018-10-11 00:53:13 +02:00
|
|
|
try:
|
|
|
|
with transaction.atomic():
|
|
|
|
PushDeviceToken.objects.create(
|
|
|
|
user_id=user_profile.id,
|
|
|
|
kind=kind,
|
|
|
|
token=token_str,
|
|
|
|
ios_app_id=ios_app_id,
|
|
|
|
# last_updated is to be renamed to date_created.
|
|
|
|
last_updated=timezone_now())
|
|
|
|
except IntegrityError:
|
|
|
|
pass
|
2017-08-19 00:42:04 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def remove_push_device_token(user_profile: UserProfile, token_str: bytes, kind: int) -> None:
|
2017-08-19 00:42:04 +02:00
|
|
|
|
|
|
|
# If we're sending things to the push notification bouncer
|
2018-02-20 06:03:12 +01:00
|
|
|
# unregister this user with them here
|
2017-08-19 00:42:04 +02:00
|
|
|
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,
|
|
|
|
}
|
2018-04-29 00:07:47 +02:00
|
|
|
# Calls zilencer.views.unregister_remote_push_device
|
2017-08-19 00:42:04 +02:00
|
|
|
send_to_push_bouncer("POST", "unregister", post_data)
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
2018-10-12 20:18:07 +02:00
|
|
|
token = PushDeviceToken.objects.get(token=token_str, kind=kind, user=user_profile)
|
2017-08-19 00:42:04 +02:00
|
|
|
token.delete()
|
|
|
|
except PushDeviceToken.DoesNotExist:
|
|
|
|
raise JsonableError(_("Token does not exist"))
|
|
|
|
|
|
|
|
#
|
|
|
|
# Push notifications in general
|
|
|
|
#
|
|
|
|
|
2018-02-12 23:34:59 +01:00
|
|
|
def push_notifications_enabled() -> bool:
|
|
|
|
'''True just if this server has configured a way to send push notifications.'''
|
|
|
|
if (uses_notification_bouncer()
|
|
|
|
and settings.ZULIP_ORG_KEY is not None
|
|
|
|
and settings.ZULIP_ORG_ID is not None): # nocoverage
|
|
|
|
# We have the needed configuration to send push notifications through
|
|
|
|
# the bouncer. Better yet would be to confirm that this config actually
|
|
|
|
# works -- e.g., that we have ever successfully sent to the bouncer --
|
|
|
|
# but this is a good start.
|
|
|
|
return True
|
|
|
|
if apns_enabled() and gcm_enabled(): # nocoverage
|
|
|
|
# We have the needed configuration to send through APNs and GCM directly
|
|
|
|
# (i.e., we are the bouncer, presumably.) Again, assume it actually works.
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2018-10-04 23:31:04 +02:00
|
|
|
def get_gcm_alert(message: Message) -> str:
|
2017-05-08 12:58:11 +02:00
|
|
|
"""
|
|
|
|
Determine what alert string to display based on the missed messages.
|
|
|
|
"""
|
|
|
|
sender_str = message.sender.full_name
|
2017-10-19 06:37:35 +02:00
|
|
|
if message.recipient.type == Recipient.HUDDLE and message.trigger == 'private_message':
|
2017-05-08 12:58:11 +02:00
|
|
|
return "New private group message from %s" % (sender_str,)
|
2017-10-19 06:37:35 +02:00
|
|
|
elif message.recipient.type == Recipient.PERSONAL and message.trigger == 'private_message':
|
2017-05-08 12:58:11 +02:00
|
|
|
return "New private message from %s" % (sender_str,)
|
2017-10-28 21:53:47 +02:00
|
|
|
elif message.is_stream_message() and message.trigger == 'mentioned':
|
2017-05-08 12:58:11 +02:00
|
|
|
return "New mention from %s" % (sender_str,)
|
2018-10-05 05:08:54 +02:00
|
|
|
else: # message.is_stream_message() and message.trigger == 'stream_push_notify'
|
2018-10-30 02:24:04 +01:00
|
|
|
return "New stream message from %s in %s" % (sender_str, get_display_recipient(message.recipient),)
|
2017-05-08 12:58:11 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def get_mobile_push_content(rendered_content: str) -> str:
|
|
|
|
def get_text(elem: LH.HtmlElement) -> str:
|
2017-10-07 00:18:07 +02:00
|
|
|
# Convert default emojis to their unicode equivalent.
|
|
|
|
classes = elem.get("class", "")
|
|
|
|
if "emoji" in classes:
|
2018-07-02 00:05:24 +02:00
|
|
|
match = re.search(r"emoji-(?P<emoji_code>\S+)", classes)
|
2017-10-07 00:18:07 +02:00
|
|
|
if match:
|
|
|
|
emoji_code = match.group('emoji_code')
|
|
|
|
char_repr = ""
|
|
|
|
for codepoint in emoji_code.split('-'):
|
|
|
|
char_repr += chr(int(codepoint, 16))
|
|
|
|
return char_repr
|
|
|
|
# Handles realm emojis, avatars etc.
|
|
|
|
if elem.tag == "img":
|
|
|
|
return elem.get("alt", "")
|
2018-05-02 07:01:29 +02:00
|
|
|
if elem.tag == 'blockquote':
|
|
|
|
return '' # To avoid empty line before quote text
|
|
|
|
return elem.text or ''
|
2017-10-07 00:18:07 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def format_as_quote(quote_text: str) -> str:
|
2018-05-02 07:01:29 +02:00
|
|
|
quote_text_list = filter(None, quote_text.split('\n')) # Remove empty lines
|
|
|
|
quote_text = '\n'.join(map(lambda x: "> "+x, quote_text_list))
|
|
|
|
quote_text += '\n'
|
|
|
|
return quote_text
|
2017-10-07 00:18:07 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def process(elem: LH.HtmlElement) -> str:
|
2017-10-07 00:18:07 +02:00
|
|
|
plain_text = get_text(elem)
|
2018-05-02 07:01:29 +02:00
|
|
|
sub_text = ''
|
2017-10-07 00:18:07 +02:00
|
|
|
for child in elem:
|
2018-05-02 07:01:29 +02:00
|
|
|
sub_text += process(child)
|
|
|
|
if elem.tag == 'blockquote':
|
|
|
|
sub_text = format_as_quote(sub_text)
|
|
|
|
plain_text += sub_text
|
2017-10-07 00:18:07 +02:00
|
|
|
plain_text += elem.tail or ""
|
|
|
|
return plain_text
|
|
|
|
|
2017-10-10 11:14:10 +02:00
|
|
|
if settings.PUSH_NOTIFICATION_REDACT_CONTENT:
|
|
|
|
return "***REDACTED***"
|
|
|
|
else:
|
|
|
|
elem = LH.fromstring(rendered_content)
|
|
|
|
plain_text = process(elem)
|
|
|
|
return plain_text
|
2017-10-07 00:18:07 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def truncate_content(content: str) -> Tuple[str, bool]:
|
2017-10-07 00:12:52 +02:00
|
|
|
# We use unicode character 'HORIZONTAL ELLIPSIS' (U+2026) instead
|
|
|
|
# of three dots as this saves two extra characters for textual
|
|
|
|
# content. This function will need to be updated to handle unicode
|
|
|
|
# combining characters and tags when we start supporting themself.
|
|
|
|
if len(content) <= 200:
|
2017-12-16 03:08:23 +01:00
|
|
|
return content, False
|
|
|
|
return content[:200] + "…", True
|
2017-10-07 00:12:52 +02:00
|
|
|
|
2017-12-16 02:52:42 +01:00
|
|
|
def get_common_payload(message: Message) -> Dict[str, Any]:
|
|
|
|
data = {} # type: Dict[str, Any]
|
2017-12-16 03:01:49 +01:00
|
|
|
|
|
|
|
# These will let the app support logging into multiple realms and servers.
|
|
|
|
data['server'] = settings.EXTERNAL_HOST
|
|
|
|
data['realm_id'] = message.sender.realm.id
|
2018-08-02 00:44:40 +02:00
|
|
|
data['realm_uri'] = message.sender.realm.uri
|
2017-12-16 03:01:49 +01:00
|
|
|
|
|
|
|
# `sender_id` is preferred, but some existing versions use `sender_email`.
|
|
|
|
data['sender_id'] = message.sender.id
|
2017-12-16 02:52:42 +01:00
|
|
|
data['sender_email'] = message.sender.email
|
2017-12-16 03:01:49 +01:00
|
|
|
|
2018-02-16 23:18:47 +01:00
|
|
|
if message.recipient.type == Recipient.STREAM:
|
2017-12-16 02:52:42 +01:00
|
|
|
data['recipient_type'] = "stream"
|
|
|
|
data['stream'] = get_display_recipient(message.recipient)
|
|
|
|
data['topic'] = message.subject
|
2018-02-16 23:18:47 +01:00
|
|
|
elif message.recipient.type == Recipient.HUDDLE:
|
|
|
|
data['recipient_type'] = "private"
|
|
|
|
data['pm_users'] = huddle_users(message.recipient.id)
|
|
|
|
else: # Recipient.PERSONAL
|
2017-12-16 02:52:42 +01:00
|
|
|
data['recipient_type'] = "private"
|
2017-12-16 03:01:49 +01:00
|
|
|
|
2017-12-16 02:52:42 +01:00
|
|
|
return data
|
|
|
|
|
2018-10-04 23:31:04 +02:00
|
|
|
def get_apns_alert_title(message: Message) -> str:
|
|
|
|
"""
|
|
|
|
On an iOS notification, this is the first bolded line.
|
|
|
|
"""
|
|
|
|
if message.recipient.type == Recipient.HUDDLE:
|
|
|
|
recipients = cast(List[Dict[str, Any]], get_display_recipient(message.recipient))
|
|
|
|
return ', '.join(sorted(r['full_name'] for r in recipients))
|
|
|
|
elif message.is_stream_message():
|
2018-10-30 02:24:04 +01:00
|
|
|
return "#%s > %s" % (get_display_recipient(message.recipient), message.topic_name(),)
|
2018-10-04 23:31:04 +02:00
|
|
|
# For personal PMs, we just show the sender name.
|
|
|
|
return message.sender.full_name
|
|
|
|
|
|
|
|
def get_apns_alert_subtitle(message: Message) -> str:
|
|
|
|
"""
|
|
|
|
On an iOS notification, this is the second bolded line.
|
|
|
|
"""
|
|
|
|
if message.trigger == "mentioned":
|
|
|
|
return message.sender.full_name + " mentioned you:"
|
|
|
|
elif message.recipient.type == Recipient.PERSONAL:
|
|
|
|
return ""
|
|
|
|
# For group PMs, or regular messages to a stream, just use a colon to indicate this is the sender.
|
|
|
|
return message.sender.full_name + ":"
|
|
|
|
|
2018-10-19 00:09:18 +02:00
|
|
|
def get_apns_payload(user_profile: UserProfile, message: Message) -> Dict[str, Any]:
|
2017-12-16 02:52:42 +01:00
|
|
|
zulip_data = get_common_payload(message)
|
|
|
|
zulip_data.update({
|
|
|
|
'message_ids': [message.id],
|
|
|
|
})
|
|
|
|
|
2017-12-16 03:08:23 +01:00
|
|
|
content, _ = truncate_content(get_mobile_push_content(message.rendered_content))
|
2017-12-12 05:40:11 +01:00
|
|
|
apns_data = {
|
2017-08-31 22:27:46 +02:00
|
|
|
'alert': {
|
2018-10-04 23:31:04 +02:00
|
|
|
'title': get_apns_alert_title(message),
|
|
|
|
'subtitle': get_apns_alert_subtitle(message),
|
2017-12-16 03:08:23 +01:00
|
|
|
'body': content,
|
2017-08-31 22:27:46 +02:00
|
|
|
},
|
2017-12-16 02:52:42 +01:00
|
|
|
'badge': 0, # TODO: set badge count in a better way
|
|
|
|
'custom': {'zulip': zulip_data},
|
|
|
|
}
|
2017-12-12 05:40:11 +01:00
|
|
|
return apns_data
|
2017-05-08 13:21:56 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def get_gcm_payload(user_profile: UserProfile, message: Message) -> Dict[str, Any]:
|
2017-12-16 02:52:42 +01:00
|
|
|
data = get_common_payload(message)
|
2017-12-16 03:08:23 +01:00
|
|
|
content, truncated = truncate_content(get_mobile_push_content(message.rendered_content))
|
2017-12-16 02:52:42 +01:00
|
|
|
data.update({
|
2017-05-08 13:26:01 +02:00
|
|
|
'user': user_profile.email,
|
|
|
|
'event': 'message',
|
2018-10-04 23:31:04 +02:00
|
|
|
'alert': get_gcm_alert(message),
|
2017-05-31 21:25:43 +02:00
|
|
|
'zulip_message_id': message.id, # message_id is reserved for CCS
|
2017-05-08 13:26:01 +02:00
|
|
|
'time': datetime_to_timestamp(message.pub_date),
|
2017-12-16 03:08:23 +01:00
|
|
|
'content': content,
|
|
|
|
'content_truncated': truncated,
|
2017-05-08 13:26:01 +02:00
|
|
|
'sender_full_name': message.sender.full_name,
|
2017-06-15 07:44:00 +02:00
|
|
|
'sender_avatar_url': absolute_avatar_url(message.sender),
|
2017-12-16 02:52:42 +01:00
|
|
|
})
|
|
|
|
return data
|
2017-05-08 13:26:01 +02:00
|
|
|
|
2018-08-02 01:28:28 +02:00
|
|
|
def handle_remove_push_notification(user_profile_id: int, message_id: int) -> None:
|
2018-07-28 14:31:45 +02:00
|
|
|
"""This should be called when a message that had previously had a
|
|
|
|
mobile push executed is read. This triggers a mobile push notifica
|
|
|
|
mobile app when the message is read on the server, to remove the
|
|
|
|
message from the notification.
|
|
|
|
|
|
|
|
"""
|
|
|
|
user_profile = get_user_profile_by_id(user_profile_id)
|
2018-08-02 01:29:06 +02:00
|
|
|
message, user_message = access_message(user_profile, message_id)
|
|
|
|
|
|
|
|
if not settings.SEND_REMOVE_PUSH_NOTIFICATIONS:
|
|
|
|
# It's a little annoying that we duplicate this flag-clearing
|
|
|
|
# code (also present below), but this block is scheduled to be
|
|
|
|
# removed in a few weeks, once the app has supported the
|
|
|
|
# feature for long enough.
|
|
|
|
user_message.flags.active_mobile_push_notification = False
|
|
|
|
user_message.save(update_fields=["flags"])
|
|
|
|
return
|
|
|
|
|
2018-07-28 14:31:45 +02:00
|
|
|
gcm_payload = get_common_payload(message)
|
|
|
|
gcm_payload.update({
|
|
|
|
'event': 'remove',
|
2018-08-02 01:28:28 +02:00
|
|
|
'zulip_message_id': message_id, # message_id is reserved for CCS
|
2018-07-28 14:31:45 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
if uses_notification_bouncer():
|
|
|
|
try:
|
|
|
|
send_notifications_to_bouncer(user_profile_id,
|
|
|
|
{},
|
|
|
|
gcm_payload)
|
|
|
|
except requests.ConnectionError: # nocoverage
|
|
|
|
def failure_processor(event: Dict[str, Any]) -> None:
|
|
|
|
logging.warning(
|
|
|
|
"Maximum retries exceeded for trigger:%s event:push_notification" % (
|
|
|
|
event['user_profile_id']))
|
|
|
|
return
|
|
|
|
|
|
|
|
android_devices = list(PushDeviceToken.objects.filter(user=user_profile,
|
|
|
|
kind=PushDeviceToken.GCM))
|
|
|
|
|
|
|
|
if android_devices:
|
|
|
|
send_android_push_notification(android_devices, gcm_payload)
|
|
|
|
|
2018-08-02 01:29:06 +02:00
|
|
|
user_message.flags.active_mobile_push_notification = False
|
|
|
|
user_message.save(update_fields=["flags"])
|
|
|
|
|
2017-03-06 03:05:04 +01:00
|
|
|
@statsd_increment("push_notifications")
|
2017-11-05 11:15:10 +01:00
|
|
|
def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any]) -> None:
|
2017-08-18 09:04:52 +02:00
|
|
|
"""
|
|
|
|
missed_message is the event received by the
|
|
|
|
zerver.worker.queue_processors.PushNotificationWorker.consume function.
|
|
|
|
"""
|
2017-11-10 00:29:52 +01:00
|
|
|
user_profile = get_user_profile_by_id(user_profile_id)
|
2017-11-29 02:49:11 +01:00
|
|
|
if not (receives_offline_push_notifications(user_profile) or
|
2017-11-10 00:29:52 +01:00
|
|
|
receives_online_notifications(user_profile)):
|
|
|
|
return
|
2017-03-06 03:05:04 +01:00
|
|
|
|
2017-11-10 00:51:06 +01:00
|
|
|
user_profile = get_user_profile_by_id(user_profile_id)
|
|
|
|
(message, user_message) = access_message(user_profile, missed_message['message_id'])
|
|
|
|
if user_message is not None:
|
|
|
|
# If ther user has read the message already, don't push-notify.
|
|
|
|
#
|
|
|
|
# TODO: It feels like this is already handled when things are
|
|
|
|
# put in the queue; maybe we should centralize this logic with
|
|
|
|
# the `zerver/tornado/event_queue.py` logic?
|
|
|
|
if user_message.flags.read:
|
|
|
|
return
|
2018-08-02 01:29:06 +02:00
|
|
|
|
|
|
|
# Otherwise, we mark the message as having an active mobile
|
|
|
|
# push notification, so that we can send revocation messages
|
|
|
|
# later.
|
|
|
|
user_message.flags.active_mobile_push_notification = True
|
|
|
|
user_message.save(update_fields=["flags"])
|
2017-11-10 00:51:06 +01:00
|
|
|
else:
|
|
|
|
# Users should only be getting push notifications into this
|
|
|
|
# queue for messages they haven't received if they're
|
|
|
|
# long-term idle; anything else is likely a bug.
|
|
|
|
if not user_profile.long_term_idle:
|
|
|
|
logging.error("Could not find UserMessage with message_id %s and user_id %s" % (
|
|
|
|
missed_message['message_id'], user_profile_id))
|
|
|
|
return
|
2017-09-10 00:47:36 +02:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
message.trigger = missed_message['trigger']
|
2017-05-08 13:21:56 +02:00
|
|
|
|
2018-10-19 00:09:18 +02:00
|
|
|
apns_payload = get_apns_payload(user_profile, message)
|
2017-11-10 00:29:52 +01:00
|
|
|
gcm_payload = get_gcm_payload(user_profile, message)
|
|
|
|
logging.info("Sending push notification to user %s" % (user_profile_id,))
|
2017-05-08 13:48:16 +02:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
if uses_notification_bouncer():
|
|
|
|
try:
|
|
|
|
send_notifications_to_bouncer(user_profile_id,
|
|
|
|
apns_payload,
|
|
|
|
gcm_payload)
|
|
|
|
except requests.ConnectionError:
|
2017-11-27 05:27:04 +01:00
|
|
|
def failure_processor(event: Dict[str, Any]) -> None:
|
2017-11-10 00:29:52 +01:00
|
|
|
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
|
2017-05-08 13:48:16 +02:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
android_devices = list(PushDeviceToken.objects.filter(user=user_profile,
|
|
|
|
kind=PushDeviceToken.GCM))
|
2017-05-08 13:48:16 +02:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
apple_devices = list(PushDeviceToken.objects.filter(user=user_profile,
|
|
|
|
kind=PushDeviceToken.APNS))
|
2017-05-12 07:50:18 +02:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
if apple_devices:
|
|
|
|
send_apple_push_notification(user_profile.id, apple_devices,
|
|
|
|
apns_payload)
|
2017-03-06 03:05:04 +01:00
|
|
|
|
2017-11-10 00:29:52 +01:00
|
|
|
if android_devices:
|
|
|
|
send_android_push_notification(android_devices, gcm_payload)
|