zilencer: Change push bouncer API to accept uuids as user identifier.

This is the first step to making the full switch to self-hosted servers
use user uuids, per issue #18017. The old id format is still supported
of course, for backward compatibility.

This commit is separate in order to allow deploying *just* the bouncer
API change to production first.
This commit is contained in:
Mateusz Mandera 2022-02-23 20:25:30 +01:00 committed by Tim Abbott
parent 75f7426e21
commit 0677c90170
5 changed files with 199 additions and 60 deletions

View File

@ -13,7 +13,7 @@ import lxml.html
import orjson
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import F
from django.db.models import F, Q
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from django.utils.translation import override as override_language
@ -58,6 +58,49 @@ def hex_to_b64(data: str) -> str:
return base64.b64encode(bytes.fromhex(data)).decode()
class UserPushIndentityCompat:
"""Compatibility class for supporting the transition from remote servers
sending their UserProfile ids to the bouncer to sending UserProfile uuids instead.
Until we can drop support for receiving user_id, we need this
class, because a user's identity in the push notification context
may be represented either by an id or uuid.
"""
def __init__(self, user_id: Optional[int] = None, user_uuid: Optional[str] = None) -> None:
assert user_id is not None or user_uuid is not None
self.user_id = user_id
self.user_uuid = user_uuid
def filter_q(self) -> Q:
"""
This aims to support correctly querying for RemotePushDeviceToken.
If only one of (user_id, user_uuid) is provided, the situation is trivial,
If both are provided, we want to query for tokens matching EITHER the
uuid or the id - because the user may have devices with old registrations,
so user_id-based, as well as new registration with uuid. Notifications
naturally should be sent to both.
"""
if self.user_id is not None and self.user_uuid is None:
return Q(user_id=self.user_id)
elif self.user_uuid is not None and self.user_id is None:
return Q(user_uuid=self.user_uuid)
else:
assert self.user_id is not None and self.user_uuid is not None
return Q(user_uuid=self.user_uuid) | Q(user_id=self.user_id)
def __str__(self) -> str:
if self.user_uuid is not None:
return f"uuid:{self.user_uuid}"
return f"id:{self.user_id}"
def __eq__(self, other: Any) -> bool:
if isinstance(other, UserPushIndentityCompat):
return self.user_id == other.user_id and self.user_uuid == other.user_uuid
return False
#
# Sending to APNs, for iOS
#
@ -134,7 +177,7 @@ APNS_MAX_RETRIES = 3
@statsd_increment("apple_push_notification")
def send_apple_push_notification(
user_id: int,
user_identity: UserPushIndentityCompat,
devices: Sequence[DeviceToken],
payload_data: Dict[str, Any],
remote: Optional["RemoteZulipServer"] = None,
@ -164,14 +207,16 @@ def send_apple_push_notification(
if remote:
logger.info(
"APNs: Sending notification for remote user %s:%d to %d devices",
"APNs: Sending notification for remote user %s:%s to %d devices",
remote.uuid,
user_id,
user_identity,
len(devices),
)
else:
logger.info(
"APNs: Sending notification for local user %d to %d devices", user_id, len(devices)
"APNs: Sending notification for local user %s to %d devices",
user_identity,
len(devices),
)
payload_data = modernize_apns_payload(payload_data).copy()
message = {**payload_data.pop("custom", {}), "aps": payload_data}
@ -187,15 +232,17 @@ def send_apple_push_notification(
)
except aioapns.exceptions.ConnectionError as e:
logger.warning(
"APNs: ConnectionError sending for user %d to device %s: %s",
user_id,
"APNs: ConnectionError sending for user %s to device %s: %s",
user_identity,
device.token,
e.__class__.__name__,
)
continue
if result.is_successful:
logger.info("APNs: Success sending for user %d to device %s", user_id, device.token)
logger.info(
"APNs: Success sending for user %s to device %s", user_identity, device.token
)
elif result.description in ["Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic"]:
logger.info(
"APNs: Removing invalid/expired token %s (%s)", device.token, result.description
@ -205,8 +252,8 @@ def send_apple_push_notification(
DeviceTokenClass.objects.filter(token=device.token, kind=DeviceTokenClass.APNS).delete()
else:
logger.warning(
"APNs: Failed to send for user %d to device %s: %s",
user_id,
"APNs: Failed to send for user %s to device %s: %s",
user_identity,
device.token,
result.description,
)
@ -256,7 +303,9 @@ def send_android_push_notification_to_user(
user_profile: UserProfile, data: Dict[str, Any], options: Dict[str, Any]
) -> None:
devices = list(PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM))
send_android_push_notification(user_profile.id, devices, data, options)
send_android_push_notification(
UserPushIndentityCompat(user_id=user_profile.id), devices, data, options
)
def parse_gcm_options(options: Dict[str, Any], data: Dict[str, Any]) -> str:
@ -306,7 +355,7 @@ def parse_gcm_options(options: Dict[str, Any], data: Dict[str, Any]) -> str:
@statsd_increment("android_push_notification")
def send_android_push_notification(
user_id: int,
user_identity: UserPushIndentityCompat,
devices: Sequence[DeviceToken],
data: Dict[str, Any],
options: Dict[str, Any],
@ -334,14 +383,14 @@ def send_android_push_notification(
if remote:
logger.info(
"GCM: Sending notification for remote user %s:%d to %d devices",
"GCM: Sending notification for remote user %s:%s to %d devices",
remote.uuid,
user_id,
user_identity,
len(devices),
)
else:
logger.info(
"GCM: Sending notification for local user %d to %d devices", user_id, len(devices)
"GCM: Sending notification for local user %s to %d devices", user_identity, len(devices)
)
reg_ids = [device.token for device in devices]
priority = parse_gcm_options(options, data)
@ -947,6 +996,7 @@ def handle_remove_push_notification(user_profile_id: int, message_ids: List[int]
if uses_notification_bouncer():
send_notifications_to_bouncer(user_profile_id, apns_payload, gcm_payload, gcm_options)
else:
user_identity = UserPushIndentityCompat(user_id=user_profile_id)
android_devices = list(
PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.GCM)
)
@ -954,11 +1004,9 @@ def handle_remove_push_notification(user_profile_id: int, message_ids: List[int]
PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.APNS)
)
if android_devices:
send_android_push_notification(
user_profile_id, android_devices, gcm_payload, gcm_options
)
send_android_push_notification(user_identity, android_devices, gcm_payload, gcm_options)
if apple_devices:
send_apple_push_notification(user_profile_id, apple_devices, apns_payload)
send_apple_push_notification(user_identity, apple_devices, apns_payload)
# We intentionally use the non-truncated message_ids here. We are
# assuming in this very rare case that the user has manually
@ -1077,5 +1125,6 @@ def handle_push_notification(user_profile_id: int, missed_message: Dict[str, Any
len(android_devices),
len(apple_devices),
)
send_apple_push_notification(user_profile.id, apple_devices, apns_payload)
send_android_push_notification(user_profile.id, android_devices, gcm_payload, gcm_options)
user_identity = UserPushIndentityCompat(user_id=user_profile.id)
send_apple_push_notification(user_identity, apple_devices, apns_payload)
send_android_push_notification(user_identity, android_devices, gcm_payload, gcm_options)

View File

@ -13,7 +13,7 @@ import orjson
import responses
from django.conf import settings
from django.db import transaction
from django.db.models import F
from django.db.models import F, Q
from django.http.response import ResponseHeaders
from django.test import override_settings
from django.utils.crypto import get_random_string
@ -34,6 +34,7 @@ from zerver.lib.exceptions import JsonableError
from zerver.lib.push_notifications import (
APNsContext,
DeviceToken,
UserPushIndentityCompat,
b64_to_hex,
get_apns_badge_count,
get_apns_badge_count_future,
@ -192,7 +193,13 @@ class PushBouncerNotificationTest(BouncerTestCase):
result = self.uuid_post(
self.server_uuid, endpoint, {"token": token, "token_kind": token_kind}
)
self.assert_json_error(result, "Missing 'user_id' argument")
self.assert_json_error(result, "Missing user_id or user_uuid")
result = self.uuid_post(
self.server_uuid,
endpoint,
{"user_id": user_id, "user_uuid": "xxx", "token": token, "token_kind": token_kind},
)
self.assert_json_error(result, "Specify only one of user_id or user_uuid")
result = self.uuid_post(
self.server_uuid, endpoint, {"user_id": user_id, "token": token, "token_kind": 17}
)
@ -367,12 +374,14 @@ class PushBouncerNotificationTest(BouncerTestCase):
logger.output,
[
"INFO:zilencer.views:"
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{hamlet.id}: "
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{hamlet.id}: "
"2 via FCM devices, 1 via APNs devices"
],
)
user_identity = UserPushIndentityCompat(user_id=hamlet.id)
apple_push.assert_called_once_with(
hamlet.id,
user_identity,
[apple_token],
{
"badge": 0,
@ -386,7 +395,7 @@ class PushBouncerNotificationTest(BouncerTestCase):
remote=server,
)
android_push.assert_called_once_with(
hamlet.id,
user_identity,
list(reversed(android_tokens)),
{"event": "remove", "zulip_message_ids": ",".join(str(i) for i in range(50, 250))},
{},
@ -962,7 +971,9 @@ class HandlePushNotificationTest(PushNotificationTest):
(b64_to_hex(device.token), device.ios_app_id, device.token)
for device in RemotePushDeviceToken.objects.filter(kind=PushDeviceToken.GCM)
]
mock_gcm.json_request.return_value = {"success": {gcm_devices[0][2]: message.id}}
mock_gcm.json_request.return_value = {
"success": {device[2]: message.id for device in gcm_devices}
}
result = mock.Mock()
result.is_successful = True
apns_context.apns.send_notification.return_value = asyncio.Future(
@ -974,14 +985,14 @@ class HandlePushNotificationTest(PushNotificationTest):
views_logger.output,
[
"INFO:zilencer.views:"
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{self.user_profile.id}: "
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{self.user_profile.id}: "
f"{len(gcm_devices)} via FCM devices, {len(apns_devices)} via APNs devices"
],
)
for _, _, token in apns_devices:
self.assertIn(
"INFO:zerver.lib.push_notifications:"
f"APNs: Success sending for user {self.user_profile.id} to device {token}",
f"APNs: Success sending for user id:{self.user_profile.id} to device {token}",
pn_logger.output,
)
for _, _, token in gcm_devices:
@ -1035,7 +1046,7 @@ class HandlePushNotificationTest(PushNotificationTest):
views_logger.output,
[
"INFO:zilencer.views:"
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:{self.user_profile.id}: "
f"Sending mobile push notifications for remote user 6cde5f7a-1f7e-4978-9716-49f69ebfc9fe:id:{self.user_profile.id}: "
f"{len(gcm_devices)} via FCM devices, {len(apns_devices)} via APNs devices"
],
)
@ -1249,10 +1260,9 @@ class HandlePushNotificationTest(PushNotificationTest):
) as mock_push_notifications:
handle_push_notification(self.user_profile.id, missed_message)
mock_send_apple.assert_called_with(self.user_profile.id, apple_devices, {"apns": True})
mock_send_android.assert_called_with(
self.user_profile.id, android_devices, {"gcm": True}, {}
)
user_identity = UserPushIndentityCompat(user_id=self.user_profile.id)
mock_send_apple.assert_called_with(user_identity, apple_devices, {"apns": True})
mock_send_android.assert_called_with(user_identity, android_devices, {"gcm": True}, {})
mock_push_notifications.assert_called_once()
def test_send_remove_notifications_to_bouncer(self) -> None:
@ -1324,8 +1334,9 @@ class HandlePushNotificationTest(PushNotificationTest):
) as mock_send_apple:
handle_remove_push_notification(self.user_profile.id, [message.id])
mock_push_notifications.assert_called_once()
user_identity = UserPushIndentityCompat(user_id=self.user_profile.id)
mock_send_android.assert_called_with(
self.user_profile.id,
user_identity,
android_devices,
{
"server": "testserver",
@ -1339,7 +1350,7 @@ class HandlePushNotificationTest(PushNotificationTest):
{"priority": "normal"},
)
mock_send_apple.assert_called_with(
self.user_profile.id,
user_identity,
apple_devices,
{
"badge": 0,
@ -1451,10 +1462,9 @@ class HandlePushNotificationTest(PushNotificationTest):
) as mock_push_notifications:
handle_push_notification(self.user_profile.id, missed_message)
mock_logger.assert_not_called()
mock_send_apple.assert_called_with(self.user_profile.id, apple_devices, {"apns": True})
mock_send_android.assert_called_with(
self.user_profile.id, android_devices, {"gcm": True}, {}
)
user_identity = UserPushIndentityCompat(user_id=self.user_profile.id)
mock_send_apple.assert_called_with(user_identity, apple_devices, {"apns": True})
mock_send_android.assert_called_with(user_identity, android_devices, {"gcm": True}, {})
mock_push_notifications.assert_called_once()
@mock.patch("zerver.lib.push_notifications.logger.info")
@ -1492,7 +1502,9 @@ class TestAPNs(PushNotificationTest):
payload_data: Dict[str, Any] = {},
) -> None:
send_apple_push_notification(
self.user_profile.id, devices if devices is not None else self.devices(), payload_data
UserPushIndentityCompat(user_id=self.user_profile.id),
devices if devices is not None else self.devices(),
payload_data,
)
def test_get_apns_context(self) -> None:
@ -1559,7 +1571,7 @@ class TestAPNs(PushNotificationTest):
self.send()
for device in self.devices():
self.assertIn(
f"INFO:zerver.lib.push_notifications:APNs: Success sending for user {self.user_profile.id} to device {device.token}",
f"INFO:zerver.lib.push_notifications:APNs: Success sending for user id:{self.user_profile.id} to device {device.token}",
logger.output,
)
@ -1576,7 +1588,7 @@ class TestAPNs(PushNotificationTest):
)
self.send(devices=self.devices()[0:1])
self.assertIn(
f"WARNING:zerver.lib.push_notifications:APNs: ConnectionError sending for user {self.user_profile.id} to device {self.devices()[0].token}: ConnectionError",
f"WARNING:zerver.lib.push_notifications:APNs: ConnectionError sending for user id:{self.user_profile.id} to device {self.devices()[0].token}: ConnectionError",
logger.output,
)
@ -1594,7 +1606,7 @@ class TestAPNs(PushNotificationTest):
apns_context.apns.send_notification.return_value.set_result(result)
self.send(devices=self.devices()[0:1])
self.assertIn(
f"WARNING:zerver.lib.push_notifications:APNs: Failed to send for user {self.user_profile.id} to device {self.devices()[0].token}: InternalServerError",
f"WARNING:zerver.lib.push_notifications:APNs: Failed to send for user id:{self.user_profile.id} to device {self.devices()[0].token}: InternalServerError",
logger.output,
)
@ -2340,7 +2352,7 @@ class GCMSendTest(PushNotificationTest):
with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger:
send_android_push_notification_to_user(self.user_profile, data, {})
self.assert_length(logger.output, 3)
log_msg1 = f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices"
log_msg1 = f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices"
log_msg2 = f"INFO:zerver.lib.push_notifications:GCM: Sent {1111} as {0}"
log_msg3 = f"INFO:zerver.lib.push_notifications:GCM: Sent {2222} as {1}"
self.assertEqual([log_msg1, log_msg2, log_msg3], logger.output)
@ -2400,7 +2412,7 @@ class GCMSendTest(PushNotificationTest):
with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger:
send_android_push_notification_to_user(self.user_profile, data, {})
self.assertEqual(
f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices",
f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices",
logger.output[0],
)
self.assertEqual(
@ -2427,7 +2439,7 @@ class GCMSendTest(PushNotificationTest):
with self.assertLogs("zerver.lib.push_notifications", level="INFO") as logger:
send_android_push_notification_to_user(self.user_profile, data, {})
self.assertEqual(
f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user {self.user_profile.id} to 2 devices",
f"INFO:zerver.lib.push_notifications:GCM: Sending notification for local user id:{self.user_profile.id} to 2 devices",
logger.output[0],
)
self.assertEqual(
@ -2668,3 +2680,24 @@ class PushBouncerSignupTest(ZulipTestCase):
self.assert_json_error(
result, f"Zulip server auth failure: key does not match role {zulip_org_id}"
)
class TestUserPushIndentityCompat(ZulipTestCase):
def test_filter_q(self) -> None:
user_identity_id = UserPushIndentityCompat(user_id=1)
user_identity_uuid = UserPushIndentityCompat(user_uuid="aaaa")
user_identity_both = UserPushIndentityCompat(user_id=1, user_uuid="aaaa")
self.assertEqual(user_identity_id.filter_q(), Q(user_id=1))
self.assertEqual(user_identity_uuid.filter_q(), Q(user_uuid="aaaa"))
self.assertEqual(user_identity_both.filter_q(), Q(user_uuid="aaaa") | Q(user_id=1))
def test_eq(self) -> None:
user_identity_a = UserPushIndentityCompat(user_id=1)
user_identity_b = UserPushIndentityCompat(user_id=1)
user_identity_c = UserPushIndentityCompat(user_id=2)
self.assertEqual(user_identity_a, user_identity_b)
self.assertNotEqual(user_identity_a, user_identity_c)
# An integer can't be equal to an instance of the class.
self.assertNotEqual(user_identity_a, 1)

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.9 on 2021-12-27 21:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0023_remotezulipserver_deactivated"),
]
operations = [
migrations.AlterField(
model_name="remotepushdevicetoken",
name="user_id",
field=models.BigIntegerField(db_index=True, null=True),
),
migrations.AddField(
model_name="remotepushdevicetoken",
name="user_uuid",
field=models.UUIDField(null=True),
),
migrations.AlterUniqueTogether(
name="remotepushdevicetoken",
unique_together={
("server", "user_uuid", "kind", "token"),
("server", "user_id", "kind", "token"),
},
),
]

View File

@ -63,10 +63,18 @@ class RemotePushDeviceToken(AbstractPushDeviceToken):
server: RemoteZulipServer = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)
# The user id on the remote server for this device
user_id: int = models.BigIntegerField(db_index=True)
user_id: int = models.BigIntegerField(db_index=True, null=True)
user_uuid: UUID = models.UUIDField(null=True)
class Meta:
unique_together = ("server", "user_id", "kind", "token")
unique_together = [
# These indexes rely on the property that in Postgres,
# NULL != NULL in the context of unique indexes, so multiple
# rows with the same values in these columns can exist
# if one of them is NULL.
("server", "user_id", "kind", "token"),
("server", "user_uuid", "kind", "token"),
]
def __str__(self) -> str:
return f"<RemotePushDeviceToken {self.server} {self.user_id}>"

View File

@ -17,6 +17,7 @@ from corporate.lib.stripe import do_deactivate_remote_server
from zerver.decorator import InvalidZulipServerKeyError, require_post
from zerver.lib.exceptions import JsonableError
from zerver.lib.push_notifications import (
UserPushIndentityCompat,
send_android_push_notification,
send_apple_push_notification,
)
@ -153,17 +154,28 @@ def register_remote_server(
def register_remote_push_device(
request: HttpRequest,
entity: Union[UserProfile, RemoteZulipServer],
user_id: int = REQ(json_validator=check_int),
user_id: Optional[int] = REQ(json_validator=check_int, default=None),
user_uuid: Optional[str] = REQ(default=None),
token: str = REQ(),
token_kind: int = REQ(json_validator=check_int),
ios_app_id: Optional[str] = None,
) -> HttpResponse:
server = validate_bouncer_token_request(entity, token, token_kind)
if user_id is None and user_uuid is None:
raise JsonableError(_("Missing user_id or user_uuid"))
if user_id is not None and user_uuid is not None:
# We don't want "hybrid" registrations with both.
# Our RemotePushDeviceToken should be either in the new uuid format
# or the legacy id one.
raise JsonableError(_("Specify only one of user_id or user_uuid"))
try:
with transaction.atomic():
RemotePushDeviceToken.objects.create(
# Exactly one of these two user identity fields will be None.
user_id=user_id,
user_uuid=user_uuid,
server=server,
kind=token_kind,
token=token,
@ -183,12 +195,15 @@ def unregister_remote_push_device(
entity: Union[UserProfile, RemoteZulipServer],
token: str = REQ(),
token_kind: int = REQ(json_validator=check_int),
user_id: int = REQ(json_validator=check_int),
user_id: Optional[int] = REQ(json_validator=check_int, default=None),
user_uuid: Optional[str] = REQ(default=None),
ios_app_id: Optional[str] = None,
) -> HttpResponse:
server = validate_bouncer_token_request(entity, token, token_kind)
user_identity = UserPushIndentityCompat(user_id=user_id, user_uuid=user_uuid)
deleted = RemotePushDeviceToken.objects.filter(
token=token, kind=token_kind, user_id=user_id, server=server
user_identity.filter_q(), token=token, kind=token_kind, server=server
).delete()
if deleted[0] == 0:
raise JsonableError(err_("Token does not exist"))
@ -200,10 +215,13 @@ def unregister_remote_push_device(
def unregister_all_remote_push_devices(
request: HttpRequest,
entity: Union[UserProfile, RemoteZulipServer],
user_id: int = REQ(json_validator=check_int),
user_id: Optional[int] = REQ(json_validator=check_int, default=None),
user_uuid: Optional[str] = REQ(default=None),
) -> HttpResponse:
server = validate_entity(entity)
RemotePushDeviceToken.objects.filter(user_id=user_id, server=server).delete()
user_identity = UserPushIndentityCompat(user_id=user_id, user_uuid=user_uuid)
RemotePushDeviceToken.objects.filter(user_identity.filter_q(), server=server).delete()
return json_success(request)
@ -215,14 +233,15 @@ def remote_server_notify_push(
) -> HttpResponse:
server = validate_entity(entity)
user_id = payload["user_id"]
user_identity = UserPushIndentityCompat(payload.get("user_id"), payload.get("user_uuid"))
gcm_payload = payload["gcm_payload"]
apns_payload = payload["apns_payload"]
gcm_options = payload.get("gcm_options", {})
android_devices = list(
RemotePushDeviceToken.objects.filter(
user_id=user_id,
user_identity.filter_q(),
kind=RemotePushDeviceToken.GCM,
server=server,
)
@ -230,7 +249,7 @@ def remote_server_notify_push(
apple_devices = list(
RemotePushDeviceToken.objects.filter(
user_id=user_id,
user_identity.filter_q(),
kind=RemotePushDeviceToken.APNS,
server=server,
)
@ -239,7 +258,7 @@ def remote_server_notify_push(
logger.info(
"Sending mobile push notifications for remote user %s:%s: %s via FCM devices, %s via APNs devices",
server.uuid,
user_id,
user_identity,
len(android_devices),
len(apple_devices),
)
@ -265,14 +284,14 @@ def remote_server_notify_push(
gcm_payload = truncate_payload(gcm_payload)
send_android_push_notification(
user_id, android_devices, gcm_payload, gcm_options, remote=server
user_identity, android_devices, gcm_payload, gcm_options, remote=server
)
if isinstance(apns_payload.get("custom"), dict) and isinstance(
apns_payload["custom"].get("zulip"), dict
):
apns_payload["custom"]["zulip"] = truncate_payload(apns_payload["custom"]["zulip"])
send_apple_push_notification(user_id, apple_devices, apns_payload, remote=server)
send_apple_push_notification(user_identity, apple_devices, apns_payload, remote=server)
return json_success(
request,