Files
zulip/zilencer/lib/push_notifications.py
Prakhar Pratyush 6ab6df96c8 push_notification: Send a list of push requests.
Earlier, we were passing a map `device_id_to_encrypted_data`
and http headers as separate fields to bouncer.

The downside of that approach is it restricts the bouncer to
process only one type of notice i.e. either notification for
a new message or removal of sent notification, because it
used to receive a fixed priority and push_type for all the
entries in the map.

Also, using map restricts the bouncer to receive only one
request per device_id. Server can't send multiple notices
to a device in a single call to bouncer.

Currently, the server isn't modelled in a way to make a
single call to the bouncer with:
* Both send-notification & remove-notification request data.
* Multiple send-notification request data to the same device.

This commit replaces the old protocol of sending data with
a list of objects where each object has the required data
for bouncer to send it to FCM or APNs.

This makes things a lot flexible and opens possibility for
server to batch requests in a different way if we'd like to.
2025-07-25 12:47:55 -07:00

199 lines
7.2 KiB
Python

import asyncio
import logging
from collections.abc import Iterable
from dataclasses import asdict
from aioapns import NotificationRequest
from django.utils.timezone import now as timezone_now
from firebase_admin import exceptions as firebase_exceptions
from firebase_admin import messaging as firebase_messaging
from firebase_admin.messaging import UnregisteredError as FCMUnregisteredError
from zerver.lib.push_notifications import (
APNsPushRequest,
FCMPushRequest,
SendNotificationResponseData,
fcm_app,
get_apns_context,
get_info_from_apns_result,
)
from zerver.models.realms import Realm
from zilencer.models import RemotePushDevice, RemoteRealm
logger = logging.getLogger(__name__)
def send_e2ee_push_notification_apple(
apns_requests: list[NotificationRequest],
apns_remote_push_devices: list[RemotePushDevice],
delete_device_ids: list[int],
) -> int:
import aioapns
successfully_sent_count = 0
apns_context = get_apns_context()
if apns_context is None:
logger.debug(
"APNs: Dropping a notification because nothing configured. "
"Set ZULIP_SERVICES_URL (or APNS_CERT_FILE)."
)
return successfully_sent_count
async def send_all_notifications() -> Iterable[
tuple[RemotePushDevice, aioapns.common.NotificationResult | BaseException]
]:
results = await asyncio.gather(
*(apns_context.apns.send_notification(request) for request in apns_requests),
return_exceptions=True,
)
return zip(apns_remote_push_devices, results, strict=False)
results = apns_context.loop.run_until_complete(send_all_notifications())
for remote_push_device, result in results:
log_context = f"to (push_account_id={remote_push_device.push_account_id}, device={remote_push_device.token})"
result_info = get_info_from_apns_result(
result,
remote_push_device,
log_context,
)
if result_info.successfully_sent:
successfully_sent_count += 1
elif result_info.delete_device_id is not None:
remote_push_device.expired_time = timezone_now()
remote_push_device.save(update_fields=["expired_time"])
delete_device_ids.append(result_info.delete_device_id)
return successfully_sent_count
def send_e2ee_push_notification_android(
fcm_requests: list[firebase_messaging.Message],
fcm_remote_push_devices: list[RemotePushDevice],
delete_device_ids: list[int],
) -> int:
try:
batch_response = firebase_messaging.send_each(fcm_requests, app=fcm_app)
except firebase_exceptions.FirebaseError:
logger.warning("Error while pushing to FCM", exc_info=True)
return 0
successfully_sent_count = 0
for idx, response in enumerate(batch_response.responses):
# We enumerate to have idx to track which token the response
# corresponds to. send_each() preserves the order of the messages,
# so this works.
remote_push_device = fcm_remote_push_devices[idx]
token = remote_push_device.token
push_account_id = remote_push_device.push_account_id
if response.success:
successfully_sent_count += 1
logger.info(
"FCM: Sent message with ID: %s to (push_account_id=%s, device=%s)",
response.message_id,
push_account_id,
token,
)
else:
error = response.exception
if isinstance(error, FCMUnregisteredError):
remote_push_device.expired_time = timezone_now()
remote_push_device.save(update_fields=["expired_time"])
delete_device_ids.append(remote_push_device.device_id)
logger.info("FCM: Removing %s due to %s", token, error.code)
else:
logger.warning(
"FCM: Delivery failed for (push_account_id=%s, device=%s): %s:%s",
push_account_id,
token,
error.__class__,
error,
)
return successfully_sent_count
def send_e2ee_push_notifications(
push_requests: list[APNsPushRequest | FCMPushRequest],
*,
realm: Realm | None = None,
remote_realm: RemoteRealm | None = None,
) -> SendNotificationResponseData:
assert (realm is None) ^ (remote_realm is None)
import aioapns
device_ids = {push_request.device_id for push_request in push_requests}
remote_push_devices = RemotePushDevice.objects.filter(
device_id__in=device_ids, expired_time__isnull=True, realm=realm, remote_realm=remote_realm
)
device_id_to_remote_push_device = {
remote_push_device.device_id: remote_push_device
for remote_push_device in remote_push_devices
}
unexpired_remote_push_device_ids = set(device_id_to_remote_push_device.keys())
# Device IDs which should be deleted on server.
# Either the device ID is invalid or the token
# associated has been marked invalid/expired by APNs/FCM.
delete_device_ids = list(
filter(lambda device_id: device_id not in unexpired_remote_push_device_ids, device_ids)
)
apns_requests = []
apns_remote_push_devices: list[RemotePushDevice] = []
fcm_requests = []
fcm_remote_push_devices: list[RemotePushDevice] = []
for push_request in push_requests:
device_id = push_request.device_id
if device_id not in unexpired_remote_push_device_ids:
continue
remote_push_device = device_id_to_remote_push_device[device_id]
if remote_push_device.token_kind == RemotePushDevice.TokenKind.APNS:
assert isinstance(push_request, APNsPushRequest)
apns_requests.append(
aioapns.NotificationRequest(
apns_topic=remote_push_device.ios_app_id,
device_token=remote_push_device.token,
message=asdict(push_request.payload),
priority=push_request.http_headers.apns_priority,
push_type=push_request.http_headers.apns_push_type,
)
)
apns_remote_push_devices.append(remote_push_device)
else:
assert isinstance(push_request, FCMPushRequest)
fcm_requests.append(
firebase_messaging.Message(
data=asdict(push_request.payload),
token=remote_push_device.token,
android=firebase_messaging.AndroidConfig(priority=push_request.fcm_priority),
)
)
fcm_remote_push_devices.append(remote_push_device)
apple_successfully_sent_count = 0
if len(apns_requests) > 0:
apple_successfully_sent_count = send_e2ee_push_notification_apple(
apns_requests, apns_remote_push_devices, delete_device_ids
)
android_successfully_sent_count = 0
if len(fcm_requests) > 0:
android_successfully_sent_count = send_e2ee_push_notification_android(
fcm_requests, fcm_remote_push_devices, delete_device_ids
)
return {
"apple_successfully_sent_count": apple_successfully_sent_count,
"android_successfully_sent_count": android_successfully_sent_count,
"delete_device_ids": delete_device_ids,
}