Skip to content

Commit

Permalink
Use default_payload in Push Payloads (#127)
Browse files Browse the repository at this point in the history
Adds support for a `default_payload` option in the /pushers/set API
  • Loading branch information
ismailgulek authored Jun 24, 2020
1 parent d63e70f commit 34f60f6
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 21 deletions.
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ gcm
to contain the 'Server key', which can be acquired from Firebase Console at:
``https://console.firebase.google.com/project/<PROJECT NAME>/settings/cloudmessaging/``

Pusher `data` configuration
=======

The following parameters can be specified in the `data` dictionary which is given when configuring the pusher
via `POST /_matrix/client/r0/pushers/set <https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-pushers-set>`_ :

* ``default_payload``: a dictionary which defines the basic payload to be sent to the notification service.
Sygnal will merge information specific to the push event into this dictionary. If unset, the empty dictionary is used.

This can be useful for clients to specify default push payload content. For instance, iOS clients will have
freedom to use silent/mutable notifications and be able to set some default alert/sound/badge fields.

Running
=======

Expand Down
1 change: 1 addition & 0 deletions changelog.d/127.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use default_payload from device data for both APNS and GCM payloads.
45 changes: 26 additions & 19 deletions sygnal/apnspushkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# limitations under the License.
import asyncio
import base64
import copy
from datetime import timezone
import logging
import os
Expand Down Expand Up @@ -226,9 +227,9 @@ async def dispatch_notification(self, n, device, context):
) as span_parent:

if n.event_id and not n.type:
payload = self._get_payload_event_id_only(n)
payload = self._get_payload_event_id_only(n, device)
else:
payload = self._get_payload_full(n, log)
payload = self._get_payload_full(n, device, log)

if payload is None:
# Nothing to do
Expand Down Expand Up @@ -278,17 +279,22 @@ async def dispatch_notification(self, n, device, context):
retry_delay, twisted_reactor=self.sygnal.reactor
)

def _get_payload_event_id_only(self, n):
def _get_payload_event_id_only(self, n, device):
"""
Constructs a payload for a notification where we know only the event ID.
Args:
n: The notification to construct a payload for.
device (Device): Device information to which the constructed payload
will be sent.
Returns:
The APNs payload as a nested dicts.
"""
payload = {}

if device.data:
payload.update(device.data.get("default_payload", {}))

if n.room_id:
payload["room_id"] = n.room_id
if n.event_id:
Expand All @@ -301,11 +307,13 @@ def _get_payload_event_id_only(self, n):

return payload

def _get_payload_full(self, n, log):
def _get_payload_full(self, n, device, log):
"""
Constructs a payload for a notification.
Args:
n: The notification to construct a payload for.
device (Device): Device information to which the constructed payload
will be sent.
log: A logger.
Returns:
Expand Down Expand Up @@ -407,13 +415,6 @@ def _get_payload_full(self, n, log):
loc_key = "MSG_FROM_USER"
loc_args = [from_display]

aps = {}
if loc_key:
aps["alert"] = {"loc-key": loc_key}

if loc_args:
aps["alert"]["loc-args"] = loc_args

badge = None
if n.counts.unread is not None:
badge = n.counts.unread
Expand All @@ -422,25 +423,31 @@ def _get_payload_full(self, n, log):
badge = 0
badge += n.counts.missed_calls

if badge is not None:
aps["badge"] = badge

if loc_key:
aps["mutable-content"] = 1

if loc_key is None and badge is None:
log.info("Nothing to do for alert of type %s", n.type)
return None

payload = {}

if n.type and device.data:
payload = copy.deepcopy(device.data.get("default_payload", {}))

payload.setdefault("aps", {})

if loc_key:
payload["aps"].setdefault("alert", {})["loc-key"] = loc_key

if loc_args:
payload["aps"].setdefault("alert", {})["loc-args"] = loc_args

if badge is not None:
payload["aps"]["badge"] = badge

if loc_key and n.room_id:
payload["room_id"] = n.room_id
if loc_key and n.event_id:
payload["event_id"] = n.event_id

payload["aps"] = aps

return payload

async def _send_notification(self, request):
Expand Down
10 changes: 8 additions & 2 deletions sygnal/gcmpushkin.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ async def dispatch_notification(self, n, device, context):

inverse_reg_id_mappings = {v: k for (k, v) in reg_id_mappings.items()}

data = GcmPushkin._build_data(n)
data = GcmPushkin._build_data(n, device)
headers = {
b"User-Agent": ["sygnal"],
b"Content-Type": ["application/json"],
Expand Down Expand Up @@ -393,16 +393,22 @@ async def dispatch_notification(self, n, device, context):
return failed

@staticmethod
def _build_data(n):
def _build_data(n, device):
"""
Build the payload data to be sent.
Args:
n: Notification to build the payload for.
device (Device): Device information to which the constructed payload
will be sent.
Returns:
JSON-compatible dict
"""
data = {}

if device.data:
data.update(device.data.get("default_payload", {}))

for attr in [
"event_id",
"type",
Expand Down
119 changes: 119 additions & 0 deletions tests/test_apns.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@
TEST_CERTFILE_PATH = "/path/to/my/certfile.pem"

DEVICE_EXAMPLE = {"app_id": "com.example.apns", "pushkey": "spqr", "pushkey_ts": 42}
DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD = {
"app_id": "com.example.apns",
"pushkey": "spqr",
"pushkey_ts": 42,
"data": {
"default_payload": {
"aps": {
"mutable-content": 1,
"alert": {"loc-key": "SINGLE_UNREAD", "loc-args": []},
}
}
},
}


class ApnsTestCase(testutils.TestCase):
Expand Down Expand Up @@ -110,6 +123,112 @@ def test_expected(self):
self.assertEqual(1, method.call_count)
((notification_req,), _kwargs) = method.call_args

self.assertEqual(
{
"room_id": "!slw48wfj34rtnrf:example.com",
"event_id": "$qTOWWTEL48yPm3uT-gdNhFcoHxfKbZuqRVnnWWSkGBs",
"aps": {
"alert": {
"loc-key": "MSG_FROM_USER_IN_ROOM_WITH_CONTENT",
"loc-args": [
"Major Tom",
"Mission Control",
"I'm floating in a most peculiar way.",
],
},
"badge": 3,
},
},
notification_req.message,
)

self.assertEqual({"rejected": []}, resp)

def test_expected_event_id_only_with_default_payload(self):
"""
Tests the expected fallback case: a good response from APNS means we pass on
a good response to the homeserver.
"""
# Arrange
method = self.apns_pushkin_snotif
method.side_effect = testutils.make_async_magic_mock(
NotificationResult("notID", "200")
)

# Act
resp = self._request(
self._make_dummy_notification_event_id_only(
[DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD]
)
)

# Assert
self.assertEqual(1, method.call_count)
((notification_req,), _kwargs) = method.call_args

self.assertEqual(
{
"room_id": "!slw48wfj34rtnrf:example.com",
"event_id": "$qTOWWTEL48yPm3uT-gdNhFcoHxfKbZuqRVnnWWSkGBs",
"unread_count": 2,
"aps": {
"alert": {"loc-key": "SINGLE_UNREAD", "loc-args": []},
"mutable-content": 1,
},
},
notification_req.message,
)

self.assertEqual({"rejected": []}, resp)

def test_expected_badge_only_with_default_payload(self):
"""
Tests the expected fallback case: a good response from APNS means we pass on
a good response to the homeserver.
"""
# Arrange
method = self.apns_pushkin_snotif
method.side_effect = testutils.make_async_magic_mock(
NotificationResult("notID", "200")
)

# Act
resp = self._request(
self._make_dummy_notification_badge_only(
[DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD]
)
)

# Assert
self.assertEqual(1, method.call_count)
((notification_req,), _kwargs) = method.call_args

self.assertEqual(
{"aps": {"badge": 2}}, notification_req.message,
)

self.assertEqual({"rejected": []}, resp)

def test_expected_full_with_default_payload(self):
"""
Tests the expected fallback case: a good response from APNS means we pass on
a good response to the homeserver.
"""
# Arrange
method = self.apns_pushkin_snotif
method.side_effect = testutils.make_async_magic_mock(
NotificationResult("notID", "200")
)

# Act
resp = self._request(
self._make_dummy_notification([DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD])
)

# Assert
self.assertEqual(1, method.call_count)
((notification_req,), _kwargs) = method.call_args

self.assertEqual(
{
"room_id": "!slw48wfj34rtnrf:example.com",
Expand Down
30 changes: 30 additions & 0 deletions tests/test_gcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@

DEVICE_EXAMPLE = {"app_id": "com.example.gcm", "pushkey": "spqr", "pushkey_ts": 42}
DEVICE_EXAMPLE2 = {"app_id": "com.example.gcm", "pushkey": "spqr2", "pushkey_ts": 42}
DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD = {
"app_id": "com.example.gcm",
"pushkey": "spqr",
"pushkey_ts": 42,
"data": {
"default_payload": {
"aps": {
"mutable-content": 1,
"alert": {"loc-key": "SINGLE_UNREAD", "loc-args": []},
}
}
},
}


class TestGcmPushkin(GcmPushkin):
Expand Down Expand Up @@ -73,6 +86,23 @@ def test_expected(self):
self.assertEqual(resp, {"rejected": []})
self.assertEqual(gcm.num_requests, 1)

def test_expected_with_default_payload(self):
"""
Tests the expected case: a good response from GCM leads to a good
response from Sygnal.
"""
gcm = self.sygnal.pushkins["com.example.gcm"]
gcm.preload_with_response(
200, {"results": [{"message_id": "msg42", "registration_id": "spqr"}]}
)

resp = self._request(
self._make_dummy_notification([DEVICE_EXAMPLE_WITH_DEFAULT_PAYLOAD])
)

self.assertEqual(resp, {"rejected": []})
self.assertEqual(gcm.num_requests, 1)

def test_rejected(self):
"""
Tests the rejected case: a pushkey rejected to GCM leads to Sygnal
Expand Down
21 changes: 21 additions & 0 deletions tests/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,27 @@ def _make_dummy_notification(self, devices):
}
}

def _make_dummy_notification_event_id_only(self, devices):
return {
"notification": {
"room_id": "!slw48wfj34rtnrf:example.com",
"event_id": "$qTOWWTEL48yPm3uT-gdNhFcoHxfKbZuqRVnnWWSkGBs",
"counts": {"unread": 2},
"devices": devices,
}
}

def _make_dummy_notification_badge_only(self, devices):
return {
"notification": {
"id": "",
"type": None,
"sender": "",
"counts": {"unread": 2},
"devices": devices,
}
}

def _request(self, payload) -> Union[dict, int]:
"""
Make a dummy request to the notify endpoint with the specified payload
Expand Down

0 comments on commit 34f60f6

Please sign in to comment.