bmsdna-app-native 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bmsdna/app_native/__init__.py +187 -0
- bmsdna/app_native/actions.py +56 -0
- bmsdna/app_native/apns.py +363 -0
- bmsdna/app_native/approvals.py +174 -0
- bmsdna/app_native/auth.py +21 -0
- bmsdna/app_native/documents.py +94 -0
- bmsdna/app_native/entra.py +625 -0
- bmsdna/app_native/live_activities.py +290 -0
- bmsdna/app_native/models.py +40 -0
- bmsdna/app_native/router.py +60 -0
- bmsdna/app_native/widgets.py +588 -0
- bmsdna_app_native-0.2.0.dist-info/METADATA +649 -0
- bmsdna_app_native-0.2.0.dist-info/RECORD +15 -0
- bmsdna_app_native-0.2.0.dist-info/WHEEL +4 -0
- bmsdna_app_native-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from .actions import NotificationActionReceived, create_notification_action_router
|
|
2
|
+
from .apns import (
|
|
3
|
+
MAX_INLINE_WIDGETS_BYTES,
|
|
4
|
+
PERMANENTLY_INVALID_TOKEN_REASONS,
|
|
5
|
+
InterruptionLevel,
|
|
6
|
+
build_alert_message,
|
|
7
|
+
build_approvals_update_message,
|
|
8
|
+
build_critical_alert_message,
|
|
9
|
+
build_widget_update_message,
|
|
10
|
+
live_activity_topic,
|
|
11
|
+
send_approvals_update,
|
|
12
|
+
send_critical_alert,
|
|
13
|
+
send_push,
|
|
14
|
+
send_silent_push,
|
|
15
|
+
send_widget_update,
|
|
16
|
+
)
|
|
17
|
+
from .approvals import (
|
|
18
|
+
Approval,
|
|
19
|
+
ApprovalDecisionReceived,
|
|
20
|
+
ApprovalDecisionResult,
|
|
21
|
+
ApprovalDetail,
|
|
22
|
+
ApprovalFeed,
|
|
23
|
+
create_approvals_router,
|
|
24
|
+
)
|
|
25
|
+
from .auth import insecure_no_auth
|
|
26
|
+
from .documents import (
|
|
27
|
+
DEFAULT_MAX_DOCUMENT_BYTES,
|
|
28
|
+
DocumentMetadata,
|
|
29
|
+
ReceivedDocument,
|
|
30
|
+
create_document_router,
|
|
31
|
+
)
|
|
32
|
+
from .entra import (
|
|
33
|
+
EntraKeysUnavailableError,
|
|
34
|
+
EntraTokenError,
|
|
35
|
+
EntraTokenVerifier,
|
|
36
|
+
EntraUser,
|
|
37
|
+
SessionCookie,
|
|
38
|
+
SignedSessionCookie,
|
|
39
|
+
bearer_token,
|
|
40
|
+
copy_session_cookie,
|
|
41
|
+
create_entra_auth_dependency,
|
|
42
|
+
)
|
|
43
|
+
from .live_activities import (
|
|
44
|
+
ATTRIBUTES_TYPE,
|
|
45
|
+
LiveActivityAttributes,
|
|
46
|
+
LiveActivityState,
|
|
47
|
+
LiveActivityTokenReceived,
|
|
48
|
+
build_live_activity_message,
|
|
49
|
+
build_live_activity_start_message,
|
|
50
|
+
create_live_activity_router,
|
|
51
|
+
send_live_activity_end,
|
|
52
|
+
send_live_activity_start,
|
|
53
|
+
send_live_activity_update,
|
|
54
|
+
)
|
|
55
|
+
from .models import DeviceRegistration, LocationUpdate
|
|
56
|
+
from .router import create_ingest_router
|
|
57
|
+
from .widgets import (
|
|
58
|
+
WIDGET_SCHEMA_VERSION,
|
|
59
|
+
ActionButton,
|
|
60
|
+
ActionCondition,
|
|
61
|
+
ActionData,
|
|
62
|
+
ActionField,
|
|
63
|
+
ActionOption,
|
|
64
|
+
ActionPage,
|
|
65
|
+
ActionWidget,
|
|
66
|
+
ChartData,
|
|
67
|
+
ChartPoint,
|
|
68
|
+
ChartSeries,
|
|
69
|
+
ChartWidget,
|
|
70
|
+
KpiData,
|
|
71
|
+
KpiWidget,
|
|
72
|
+
ListData,
|
|
73
|
+
ListItem,
|
|
74
|
+
ListWidget,
|
|
75
|
+
NewsData,
|
|
76
|
+
NewsItem,
|
|
77
|
+
NewsWidget,
|
|
78
|
+
ProgressData,
|
|
79
|
+
ProgressWidget,
|
|
80
|
+
StatItem,
|
|
81
|
+
StatsData,
|
|
82
|
+
StatsWidget,
|
|
83
|
+
TextData,
|
|
84
|
+
TextWidget,
|
|
85
|
+
Trend,
|
|
86
|
+
Widget,
|
|
87
|
+
WidgetActionReceived,
|
|
88
|
+
WidgetActionResult,
|
|
89
|
+
WidgetFeed,
|
|
90
|
+
WidgetOptionsRequest,
|
|
91
|
+
WidgetOptionsResult,
|
|
92
|
+
WidgetRequestContext,
|
|
93
|
+
create_widget_action_router,
|
|
94
|
+
create_widget_feed_router,
|
|
95
|
+
create_widget_options_router,
|
|
96
|
+
dump_widgets,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"DEFAULT_MAX_DOCUMENT_BYTES",
|
|
101
|
+
"ATTRIBUTES_TYPE",
|
|
102
|
+
"MAX_INLINE_WIDGETS_BYTES",
|
|
103
|
+
"PERMANENTLY_INVALID_TOKEN_REASONS",
|
|
104
|
+
"WIDGET_SCHEMA_VERSION",
|
|
105
|
+
"ActionButton",
|
|
106
|
+
"ActionCondition",
|
|
107
|
+
"ActionData",
|
|
108
|
+
"ActionField",
|
|
109
|
+
"ActionOption",
|
|
110
|
+
"ActionPage",
|
|
111
|
+
"ActionWidget",
|
|
112
|
+
"Approval",
|
|
113
|
+
"ApprovalDecisionReceived",
|
|
114
|
+
"ApprovalDecisionResult",
|
|
115
|
+
"ApprovalDetail",
|
|
116
|
+
"ApprovalFeed",
|
|
117
|
+
"ChartData",
|
|
118
|
+
"ChartPoint",
|
|
119
|
+
"ChartSeries",
|
|
120
|
+
"ChartWidget",
|
|
121
|
+
"DeviceRegistration",
|
|
122
|
+
"DocumentMetadata",
|
|
123
|
+
"EntraKeysUnavailableError",
|
|
124
|
+
"EntraTokenError",
|
|
125
|
+
"EntraTokenVerifier",
|
|
126
|
+
"EntraUser",
|
|
127
|
+
"InterruptionLevel",
|
|
128
|
+
"KpiData",
|
|
129
|
+
"KpiWidget",
|
|
130
|
+
"ListData",
|
|
131
|
+
"ListItem",
|
|
132
|
+
"ListWidget",
|
|
133
|
+
"LiveActivityAttributes",
|
|
134
|
+
"LiveActivityState",
|
|
135
|
+
"LiveActivityTokenReceived",
|
|
136
|
+
"LocationUpdate",
|
|
137
|
+
"NewsData",
|
|
138
|
+
"NewsItem",
|
|
139
|
+
"NewsWidget",
|
|
140
|
+
"NotificationActionReceived",
|
|
141
|
+
"ProgressData",
|
|
142
|
+
"ProgressWidget",
|
|
143
|
+
"ReceivedDocument",
|
|
144
|
+
"SessionCookie",
|
|
145
|
+
"SignedSessionCookie",
|
|
146
|
+
"StatItem",
|
|
147
|
+
"StatsData",
|
|
148
|
+
"StatsWidget",
|
|
149
|
+
"TextData",
|
|
150
|
+
"TextWidget",
|
|
151
|
+
"Trend",
|
|
152
|
+
"Widget",
|
|
153
|
+
"WidgetActionReceived",
|
|
154
|
+
"WidgetActionResult",
|
|
155
|
+
"WidgetFeed",
|
|
156
|
+
"WidgetOptionsRequest",
|
|
157
|
+
"WidgetOptionsResult",
|
|
158
|
+
"WidgetRequestContext",
|
|
159
|
+
"build_alert_message",
|
|
160
|
+
"build_approvals_update_message",
|
|
161
|
+
"build_critical_alert_message",
|
|
162
|
+
"build_live_activity_message",
|
|
163
|
+
"build_live_activity_start_message",
|
|
164
|
+
"bearer_token",
|
|
165
|
+
"build_widget_update_message",
|
|
166
|
+
"copy_session_cookie",
|
|
167
|
+
"create_approvals_router",
|
|
168
|
+
"create_document_router",
|
|
169
|
+
"create_entra_auth_dependency",
|
|
170
|
+
"create_ingest_router",
|
|
171
|
+
"create_live_activity_router",
|
|
172
|
+
"create_notification_action_router",
|
|
173
|
+
"create_widget_action_router",
|
|
174
|
+
"create_widget_feed_router",
|
|
175
|
+
"create_widget_options_router",
|
|
176
|
+
"dump_widgets",
|
|
177
|
+
"insecure_no_auth",
|
|
178
|
+
"live_activity_topic",
|
|
179
|
+
"send_approvals_update",
|
|
180
|
+
"send_critical_alert",
|
|
181
|
+
"send_live_activity_end",
|
|
182
|
+
"send_live_activity_start",
|
|
183
|
+
"send_live_activity_update",
|
|
184
|
+
"send_push",
|
|
185
|
+
"send_silent_push",
|
|
186
|
+
"send_widget_update",
|
|
187
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Router for interactive push actions (buttons in the notifications tab of
|
|
2
|
+
the iOS app, e.g. "Accept"/"Decline" — see
|
|
3
|
+
`send_push(data={"actions": [...]})` in `apns.py`).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Request
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .widgets import WidgetRequestContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotificationActionReceived(BaseModel):
|
|
18
|
+
device_id: str
|
|
19
|
+
# The action the user tapped — the `id` from the `"actions"` entry that
|
|
20
|
+
# `send_push(data={"actions": [...]})` sent along, e.g.
|
|
21
|
+
# "confirm"/"reject".
|
|
22
|
+
action: str
|
|
23
|
+
# The custom data from the original push (e.g. an order ID), so you know
|
|
24
|
+
# what the action refers to.
|
|
25
|
+
data: dict[str, Any] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
NotificationActionCallback = Callable[
|
|
29
|
+
[NotificationActionReceived, WidgetRequestContext], Awaitable[None]
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_notification_action_router(
|
|
34
|
+
*,
|
|
35
|
+
on_action_received: NotificationActionCallback,
|
|
36
|
+
auth_dependency: Callable[..., Any],
|
|
37
|
+
) -> APIRouter:
|
|
38
|
+
"""Builds an APIRouter for `POST <prefix>/notification-actions`.
|
|
39
|
+
|
|
40
|
+
Same as for devices/locations/documents: no storage of its own, the
|
|
41
|
+
action goes unchanged to your callback with the request context — e.g. to
|
|
42
|
+
mark an order as accepted. Check that `context.user` may act on what the
|
|
43
|
+
action refers to: `data` comes from the device.
|
|
44
|
+
"""
|
|
45
|
+
router = APIRouter()
|
|
46
|
+
|
|
47
|
+
@router.post("/notification-actions")
|
|
48
|
+
async def receive_action(
|
|
49
|
+
body: NotificationActionReceived, request: Request, user: Any = Depends(auth_dependency)
|
|
50
|
+
) -> dict[str, bool]:
|
|
51
|
+
await on_action_received(
|
|
52
|
+
body, WidgetRequestContext(device_id=body.device_id, user=user, request=request)
|
|
53
|
+
)
|
|
54
|
+
return {"ok": True}
|
|
55
|
+
|
|
56
|
+
return router
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""APNs delivery via aioapns (HTTP/2 + JWT auth key, async-native).
|
|
2
|
+
|
|
3
|
+
A provider is created lazily and reused for the lifetime of the process.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
12
|
+
|
|
13
|
+
from aioapns import APNs, NotificationRequest
|
|
14
|
+
from aioapns.common import PushType
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .widgets import Widget
|
|
18
|
+
|
|
19
|
+
_client: APNs | None = None
|
|
20
|
+
|
|
21
|
+
#: Error descriptions (`aioapns`' `NotificationResult.description`, Apple's
|
|
22
|
+
#: `reason` field) that make a device token *permanently* invalid — unlike
|
|
23
|
+
#: transient errors (e.g. rate limiting), a retry is pointless here.
|
|
24
|
+
#: `"BadDeviceToken"` usually means a wrong format or a sandbox/production
|
|
25
|
+
#: mix-up, `"Unregistered"` practically always means the app was
|
|
26
|
+
#: uninstalled. Check `detail in PERMANENTLY_INVALID_TOKEN_REASONS` after a
|
|
27
|
+
#: failed `send_push`/`send_silent_push` and delete the device (or at least
|
|
28
|
+
#: its `push_token`) from your own storage — otherwise your device list keeps
|
|
29
|
+
#: growing without bound with dead entries that will never be delivered
|
|
30
|
+
#: successfully again.
|
|
31
|
+
PERMANENTLY_INVALID_TOKEN_REASONS = frozenset({"BadDeviceToken", "Unregistered"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_key() -> str:
|
|
35
|
+
"""Contents of the APNs auth key (.p8) from `APNS_KEY`.
|
|
36
|
+
|
|
37
|
+
The PEM text is stored directly in the variable (in production via a
|
|
38
|
+
Key Vault reference in the App Service settings). Line breaks may be
|
|
39
|
+
written as `\\n`, because many environments don't allow multi-line
|
|
40
|
+
values.
|
|
41
|
+
"""
|
|
42
|
+
key = os.environ.get("APNS_KEY", "").replace("\\n", "\n").strip()
|
|
43
|
+
if not key:
|
|
44
|
+
raise RuntimeError("APNs is not configured. APNS_KEY is missing (see README).")
|
|
45
|
+
if not key.startswith("-----BEGIN PRIVATE KEY-----"):
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"APNS_KEY is not PEM text (expected: -----BEGIN PRIVATE KEY-----, "
|
|
48
|
+
"i.e. the contents of the .p8 file, not its path)."
|
|
49
|
+
)
|
|
50
|
+
return key
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_client() -> APNs:
|
|
54
|
+
global _client
|
|
55
|
+
if _client is not None:
|
|
56
|
+
return _client
|
|
57
|
+
|
|
58
|
+
key_id = os.environ.get("APNS_KEY_ID")
|
|
59
|
+
team_id = os.environ.get("APNS_TEAM_ID")
|
|
60
|
+
bundle_id = os.environ.get("APNS_BUNDLE_ID")
|
|
61
|
+
|
|
62
|
+
if not all([key_id, team_id, bundle_id]):
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"APNs is not configured. APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID "
|
|
65
|
+
"and APNS_BUNDLE_ID must be set as environment variables "
|
|
66
|
+
"(see README)."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_client = APNs(
|
|
70
|
+
key=_load_key(),
|
|
71
|
+
key_id=key_id,
|
|
72
|
+
team_id=team_id,
|
|
73
|
+
topic=bundle_id,
|
|
74
|
+
use_sandbox=os.environ.get("APNS_USE_SANDBOX", "true").lower() == "true",
|
|
75
|
+
)
|
|
76
|
+
return _client
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def live_activity_topic() -> str:
|
|
80
|
+
"""APNs topic for Live Activity pushes: bundle ID plus
|
|
81
|
+
`.push-type.liveactivity` (see `live_activities.py`)."""
|
|
82
|
+
bundle_id = os.environ.get("APNS_BUNDLE_ID")
|
|
83
|
+
if not bundle_id:
|
|
84
|
+
raise RuntimeError("APNS_BUNDLE_ID is not set (see README).")
|
|
85
|
+
return f"{bundle_id}.push-type.liveactivity"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: How strongly a push interrupts the user (Apple's `interruption-level`):
|
|
89
|
+
#:
|
|
90
|
+
#: - `"passive"`: silent, only lands in Notification Center.
|
|
91
|
+
#: - `"active"`: normal (default).
|
|
92
|
+
#: - `"time-sensitive"`: also breaks through a Focus mode or a scheduled
|
|
93
|
+
#: summary. For urgent things that can't wait.
|
|
94
|
+
#: - `"critical"`: critical alert, rings even with the mute switch on and
|
|
95
|
+
#: "Do Not Disturb". Only for real emergencies (evacuation, safety),
|
|
96
|
+
#: preferably via `send_critical_alert`. Only takes effect once Apple has
|
|
97
|
+
#: granted the app the entitlement; until then the push arrives as a
|
|
98
|
+
#: normal one.
|
|
99
|
+
InterruptionLevel = Literal["passive", "active", "time-sensitive", "critical"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_alert_message(
|
|
103
|
+
*,
|
|
104
|
+
title: str,
|
|
105
|
+
body: str,
|
|
106
|
+
data: dict[str, Any] | None = None,
|
|
107
|
+
interruption_level: InterruptionLevel | None = None,
|
|
108
|
+
critical_volume: float = 1.0,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Pure payload construction, extracted separately from `send_push` so
|
|
111
|
+
it can be tested without APNs credentials."""
|
|
112
|
+
aps: dict[str, Any] = {"alert": {"title": title, "body": body}, "sound": "default"}
|
|
113
|
+
if interruption_level is not None:
|
|
114
|
+
aps["interruption-level"] = interruption_level
|
|
115
|
+
if interruption_level == "critical":
|
|
116
|
+
# Critical sound: rings even with the mute switch on, at this
|
|
117
|
+
# volume (0.0 to 1.0).
|
|
118
|
+
aps["sound"] = {
|
|
119
|
+
"critical": 1,
|
|
120
|
+
"name": "default",
|
|
121
|
+
"volume": min(max(critical_volume, 0.0), 1.0),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
message: dict[str, Any] = {"aps": aps}
|
|
125
|
+
if data:
|
|
126
|
+
message.update(data)
|
|
127
|
+
return message
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def send_push(
|
|
131
|
+
*,
|
|
132
|
+
device_token: str,
|
|
133
|
+
title: str,
|
|
134
|
+
body: str,
|
|
135
|
+
data: dict[str, Any] | None = None,
|
|
136
|
+
interruption_level: InterruptionLevel | None = None,
|
|
137
|
+
) -> tuple[bool, str | None]:
|
|
138
|
+
"""The actual "one-call" push: call it directly from your own code as
|
|
139
|
+
soon as you have an APNs device token (from your own storage — see
|
|
140
|
+
`create_ingest_router`).
|
|
141
|
+
|
|
142
|
+
Returns (successful, error description) instead of raising — if you call
|
|
143
|
+
this for several devices in a loop, a single failure shouldn't stop the
|
|
144
|
+
others.
|
|
145
|
+
|
|
146
|
+
For a deep link when the notification is tapped: pass
|
|
147
|
+
`data={"path": "/orders/123"}` — the iOS app reads `path` from the
|
|
148
|
+
notification user info and navigates there in the WebView (relative to
|
|
149
|
+
`webAppURL`).
|
|
150
|
+
|
|
151
|
+
For confirm/reject buttons that the user can tap in the notifications
|
|
152
|
+
tab of the iOS app (without the app having to know about them
|
|
153
|
+
beforehand): pass
|
|
154
|
+
`data={"actions": [{"id": "confirm", "title": "Confirm"},
|
|
155
|
+
{"id": "reject", "title": "Decline"}]}`.
|
|
156
|
+
When the user taps one, its `id` arrives as `action` in
|
|
157
|
+
`create_notification_action_router()`, together with the remaining
|
|
158
|
+
`data` fields of this push (e.g. an order ID). There are **no** buttons
|
|
159
|
+
directly on the system banner/Lock Screen — for that, iOS would require
|
|
160
|
+
the app to know and register this category beforehand, which would need
|
|
161
|
+
an App Store release for every new action.
|
|
162
|
+
|
|
163
|
+
For urgent things that should also break through a Focus mode:
|
|
164
|
+
`interruption_level="time-sensitive"` (see `InterruptionLevel`). For
|
|
165
|
+
real emergencies there is `send_critical_alert`.
|
|
166
|
+
|
|
167
|
+
On `sent=False`: check whether `detail in PERMANENTLY_INVALID_TOKEN_REASONS`
|
|
168
|
+
— if so, the token is permanently dead (usually an uninstall) and should
|
|
169
|
+
be removed from your storage instead of being retried next time.
|
|
170
|
+
"""
|
|
171
|
+
client = _get_client()
|
|
172
|
+
message = build_alert_message(
|
|
173
|
+
title=title, body=body, data=data, interruption_level=interruption_level
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
request = NotificationRequest(device_token=device_token, message=message)
|
|
177
|
+
response = await client.send_notification(request)
|
|
178
|
+
|
|
179
|
+
return response.is_successful, getattr(response, "description", None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def send_silent_push(
|
|
183
|
+
*, device_token: str, data: dict[str, Any] | None = None
|
|
184
|
+
) -> tuple[bool, str | None]:
|
|
185
|
+
"""Silent push (`content-available`, no banner/sound) — briefly wakes the
|
|
186
|
+
app in the background, e.g. to reload the WebView before the user opens
|
|
187
|
+
the app.
|
|
188
|
+
|
|
189
|
+
Two things throttle this automatically, so you don't accidentally drain
|
|
190
|
+
the battery:
|
|
191
|
+
- **iOS itself** only delivers silent pushes "best effort" (no real-time
|
|
192
|
+
guarantee, the system may delay/drop delivery, depending among other
|
|
193
|
+
things on usage patterns).
|
|
194
|
+
- The **iOS app additionally throttles on the client side**
|
|
195
|
+
(default 15 minutes) — if
|
|
196
|
+
several silent pushes arrive in quicker succession, the
|
|
197
|
+
surplus ones are ignored instead of reloading the WebView every time.
|
|
198
|
+
|
|
199
|
+
If your backend itself sends very frequently (e.g. on every record
|
|
200
|
+
update), you should still throttle on the server side yourself (e.g. "at
|
|
201
|
+
most every X minutes per device") — the toolkit deliberately keeps no
|
|
202
|
+
state for this, that would be your own storage.
|
|
203
|
+
|
|
204
|
+
On `sent=False`: see `PERMANENTLY_INVALID_TOKEN_REASONS` — for a
|
|
205
|
+
permanently invalid reason, a retry is pointless.
|
|
206
|
+
"""
|
|
207
|
+
client = _get_client()
|
|
208
|
+
|
|
209
|
+
message: dict[str, Any] = {"aps": {"content-available": 1}}
|
|
210
|
+
if data:
|
|
211
|
+
message.update(data)
|
|
212
|
+
|
|
213
|
+
request = NotificationRequest(
|
|
214
|
+
device_token=device_token,
|
|
215
|
+
message=message,
|
|
216
|
+
push_type=PushType.BACKGROUND,
|
|
217
|
+
priority=5,
|
|
218
|
+
)
|
|
219
|
+
response = await client.send_notification(request)
|
|
220
|
+
|
|
221
|
+
return response.is_successful, getattr(response, "description", None)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
#: Upper limit for widgets sent directly in the push. APNs allows 4 KB per
|
|
225
|
+
#: payload, the rest is headroom for `aps` and `webapp_id`. Larger updates
|
|
226
|
+
#: are automatically sent by `send_widget_update` as a plain
|
|
227
|
+
#: `widgets_refresh`, and the app then fetches the feed itself.
|
|
228
|
+
MAX_INLINE_WIDGETS_BYTES = 3500
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_widget_update_message(
|
|
232
|
+
*, webapp_id: str, widgets: Sequence[Widget] | None = None
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Pure payload construction for `send_widget_update`, testable without
|
|
235
|
+
APNs credentials."""
|
|
236
|
+
from .widgets import dump_widgets
|
|
237
|
+
|
|
238
|
+
message: dict[str, Any] = {"aps": {"content-available": 1}, "webapp_id": webapp_id}
|
|
239
|
+
if widgets is not None:
|
|
240
|
+
inline = {**message, "widgets": dump_widgets(widgets)}
|
|
241
|
+
size = len(json.dumps(inline, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
|
|
242
|
+
if size <= MAX_INLINE_WIDGETS_BYTES:
|
|
243
|
+
return inline
|
|
244
|
+
message["widgets_refresh"] = True
|
|
245
|
+
return message
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def send_widget_update(
|
|
249
|
+
*, device_token: str, webapp_id: str, widgets: Sequence[Widget] | None = None
|
|
250
|
+
) -> tuple[bool, str | None]:
|
|
251
|
+
"""Updates your web app's dashboard widgets on a device via silent push,
|
|
252
|
+
without a banner and without your web app having to be open.
|
|
253
|
+
|
|
254
|
+
- **With `widgets`**: The widgets are sent directly in the push and
|
|
255
|
+
replace ALL widgets of your web app on the device. This is the same as
|
|
256
|
+
your feed, so always send the complete list, not just the changed
|
|
257
|
+
widget. If the list doesn't fit into a push
|
|
258
|
+
(`MAX_INLINE_WIDGETS_BYTES`), only a feed fetch is triggered
|
|
259
|
+
automatically.
|
|
260
|
+
- **Without `widgets`**: The app reloads your feed
|
|
261
|
+
(`create_widget_feed_router`) immediately. This requires the feed path
|
|
262
|
+
to be configured in the app (`widgetFeedPath`).
|
|
263
|
+
|
|
264
|
+
`webapp_id` is the `id` of your web app in the app. The app only
|
|
265
|
+
accepts widgets from web apps that have `.dashboardWidgets`
|
|
266
|
+
enabled.
|
|
267
|
+
|
|
268
|
+
iOS only delivers silent pushes "best effort". For guaranteed up-to-date
|
|
269
|
+
data the feed remains the source; the app fetches it anyway every time
|
|
270
|
+
it returns to the foreground. Return value and error handling as for
|
|
271
|
+
`send_silent_push`.
|
|
272
|
+
"""
|
|
273
|
+
message = build_widget_update_message(webapp_id=webapp_id, widgets=widgets)
|
|
274
|
+
data = {key: value for key, value in message.items() if key != "aps"}
|
|
275
|
+
return await send_silent_push(device_token=device_token, data=data)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_critical_alert_message(
|
|
279
|
+
*,
|
|
280
|
+
webapp_id: str,
|
|
281
|
+
title: str,
|
|
282
|
+
body: str,
|
|
283
|
+
acknowledge_label: str | None = "Got it",
|
|
284
|
+
volume: float = 1.0,
|
|
285
|
+
data: dict[str, Any] | None = None,
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""Pure payload construction for `send_critical_alert`, testable without
|
|
288
|
+
APNs credentials."""
|
|
289
|
+
extra: dict[str, Any] = {**(data or {}), "webapp_id": webapp_id}
|
|
290
|
+
if acknowledge_label:
|
|
291
|
+
extra["ack_label"] = acknowledge_label
|
|
292
|
+
return build_alert_message(
|
|
293
|
+
title=title,
|
|
294
|
+
body=body,
|
|
295
|
+
data=extra,
|
|
296
|
+
interruption_level="critical",
|
|
297
|
+
critical_volume=volume,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def send_critical_alert(
|
|
302
|
+
*,
|
|
303
|
+
device_token: str,
|
|
304
|
+
webapp_id: str,
|
|
305
|
+
title: str,
|
|
306
|
+
body: str,
|
|
307
|
+
acknowledge_label: str | None = "Got it",
|
|
308
|
+
volume: float = 1.0,
|
|
309
|
+
data: dict[str, Any] | None = None,
|
|
310
|
+
) -> tuple[bool, str | None]:
|
|
311
|
+
"""Critical alert for real emergencies (evacuation, severe weather,
|
|
312
|
+
security incident): rings even with the mute switch on and "Do Not
|
|
313
|
+
Disturb", and additionally appears full-screen in the app.
|
|
314
|
+
|
|
315
|
+
With `acknowledge_label` (e.g. "I am safe") the app shows a confirm
|
|
316
|
+
button. When the user taps it, this arrives as
|
|
317
|
+
`action="acknowledge"` in `create_notification_action_router()`,
|
|
318
|
+
together with your `data` fields (e.g. pass an `alert_id`). This way
|
|
319
|
+
you see who has seen the alert. `None` hides the button.
|
|
320
|
+
|
|
321
|
+
The app side needs Apple's entitlement for critical alerts (see
|
|
322
|
+
README). Until then the push arrives as a normal notification; the
|
|
323
|
+
acknowledgement in the app still works. Return value and error
|
|
324
|
+
handling as for `send_push`.
|
|
325
|
+
"""
|
|
326
|
+
client = _get_client()
|
|
327
|
+
message = build_critical_alert_message(
|
|
328
|
+
webapp_id=webapp_id,
|
|
329
|
+
title=title,
|
|
330
|
+
body=body,
|
|
331
|
+
acknowledge_label=acknowledge_label,
|
|
332
|
+
volume=volume,
|
|
333
|
+
data=data,
|
|
334
|
+
)
|
|
335
|
+
request = NotificationRequest(device_token=device_token, message=message)
|
|
336
|
+
response = await client.send_notification(request)
|
|
337
|
+
return response.is_successful, getattr(response, "description", None)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_approvals_update_message(*, webapp_id: str) -> dict[str, Any]:
|
|
341
|
+
"""Pure payload construction for `send_approvals_update`, testable
|
|
342
|
+
without APNs credentials."""
|
|
343
|
+
return {
|
|
344
|
+
"aps": {"content-available": 1},
|
|
345
|
+
"webapp_id": webapp_id,
|
|
346
|
+
"approvals_refresh": True,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def send_approvals_update(*, device_token: str, webapp_id: str) -> tuple[bool, str | None]:
|
|
351
|
+
"""Makes the app reload your web app's pending approvals immediately
|
|
352
|
+
(`create_approvals_router`), via silent push without a banner. E.g. when
|
|
353
|
+
a new request comes in or someone else has handled one.
|
|
354
|
+
|
|
355
|
+
For a visible notification ("New vacation request from Anna") you
|
|
356
|
+
additionally send a normal `send_push`. iOS only delivers silent pushes
|
|
357
|
+
"best effort"; the app reloads the feed anyway every time it returns to
|
|
358
|
+
the foreground. Return value and error handling as for
|
|
359
|
+
`send_silent_push`.
|
|
360
|
+
"""
|
|
361
|
+
message = build_approvals_update_message(webapp_id=webapp_id)
|
|
362
|
+
data = {key: value for key, value in message.items() if key != "aps"}
|
|
363
|
+
return await send_silent_push(device_token=device_token, data=data)
|