neon-phal-plugin-notifications 0.1.0__tar.gz
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.
- neon_phal_plugin_notifications-0.1.0/LICENSE.md +21 -0
- neon_phal_plugin_notifications-0.1.0/PKG-INFO +146 -0
- neon_phal_plugin_notifications-0.1.0/README.md +109 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/__init__.py +259 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/authorization.py +93 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/identity.py +70 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/legacy.py +101 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/manager.py +334 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/policy.py +144 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/scheduler.py +72 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/store.py +260 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/PKG-INFO +146 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/SOURCES.txt +23 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/dependency_links.txt +1 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/entry_points.txt +2 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/requires.txt +14 -0
- neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/top_level.txt +1 -0
- neon_phal_plugin_notifications-0.1.0/pyproject.toml +79 -0
- neon_phal_plugin_notifications-0.1.0/setup.cfg +4 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_legacy.py +46 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_manager.py +393 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_plugin.py +288 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_policy.py +78 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_scheduler.py +27 -0
- neon_phal_plugin_notifications-0.1.0/tests/test_store.py +136 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System
|
|
2
|
+
# All trademark and other rights reserved by their respective owners
|
|
3
|
+
# Copyright 2008-2026 Neongecko.com Inc.
|
|
4
|
+
# BSD-3 License
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
|
|
7
|
+
following conditions are met:
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
|
|
9
|
+
disclaimer.
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
|
|
11
|
+
disclaimer in the documentation and/or other materials provided with the distribution.
|
|
12
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
|
|
13
|
+
derived from this software without specific prior written permission.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
|
16
|
+
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
17
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
18
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
|
20
|
+
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
|
21
|
+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neon-phal-plugin-notifications
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Notification Manager PHAL plugin for Neon/OVOS: owns notification state, persistence, snooze/dismiss, and emission policy
|
|
5
|
+
Author-email: Mike Gray <mike@oscillatelabs.net>
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/NeonGeckoCom/neon-phal-plugin-notifications
|
|
8
|
+
Project-URL: Repository, https://github.com/NeonGeckoCom/neon-phal-plugin-notifications.git
|
|
9
|
+
Project-URL: Issues, https://github.com/NeonGeckoCom/neon-phal-plugin-notifications/issues
|
|
10
|
+
Keywords: ovos,neon,phal,plugin,notifications,voice assistant
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE.md
|
|
24
|
+
Requires-Dist: neon-data-models>=0.0.3a5
|
|
25
|
+
Requires-Dist: ovos-utils~=0.8
|
|
26
|
+
Requires-Dist: ovos-bus-client~=1.5
|
|
27
|
+
Requires-Dist: ovos-plugin-manager~=2.2
|
|
28
|
+
Requires-Dist: json_database~=0.10
|
|
29
|
+
Requires-Dist: pydantic~=2.9
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: ruff>=0.0.291; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# neon-phal-plugin-notifications
|
|
39
|
+
|
|
40
|
+
Notification Manager for Neon/OVOS, packaged as a PHAL plugin. It is the single
|
|
41
|
+
chokepoint for `ovos.notification.api.*` traffic on the hub messagebus: it
|
|
42
|
+
validates producer requests, owns notification state (active / snoozed /
|
|
43
|
+
dismissed / expired), persists that state across restarts, enforces emission
|
|
44
|
+
and dismissal policy, and emits `notify` / `dismiss` / `snoozed` events for
|
|
45
|
+
consumers (the Node app today, other plugins later).
|
|
46
|
+
|
|
47
|
+
The plugin never renders anything and never talks to MQ. Bus in, bus out.
|
|
48
|
+
Transport lives in `neon-messagebus-mq-connector` and `neon-hana`; rendering
|
|
49
|
+
lives in consumers.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
Notification data models come from `neon-data-models`. Until the next release
|
|
54
|
+
they live on the `FEAT_Notifications` branch; install that branch first:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install git+https://github.com/NeonGeckoCom/neon-data-models@FEAT_Notifications
|
|
58
|
+
pip install neon-phal-plugin-notifications
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
All keys live under the plugin's PHAL section. Every key is optional.
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
PHAL:
|
|
67
|
+
neon-phal-plugin-notifications:
|
|
68
|
+
store: json # persistence adapter; only "json" ships
|
|
69
|
+
store_path: null # override the JSON file path (default:
|
|
70
|
+
# $XDG_DATA_HOME/neon/neon-phal-plugin-notifications/notifications.json)
|
|
71
|
+
allow_global: # skill_ids permitted to use scope=GLOBAL
|
|
72
|
+
- skill-alerts.neongeckocom
|
|
73
|
+
allow_non_removable: # skill_ids permitted to set removable_by_user=false
|
|
74
|
+
- skill-alerts.neongeckocom
|
|
75
|
+
rate_limit: 30 # accepted `set` requests per producer per window
|
|
76
|
+
rate_window_seconds: 3600
|
|
77
|
+
consumers_allowed_to_dismiss: [] # empty/absent = every consumer may dismiss
|
|
78
|
+
consumers_blocked_from_dismiss: []
|
|
79
|
+
retention_max_age_days: 7 # tombstone window for dismissed/expired entries
|
|
80
|
+
tick_seconds: 60 # expiry + retention sweep interval
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Policy defaults are asymmetric on purpose: dismissal is open, high-impact
|
|
84
|
+
emission (GLOBAL scope, non-removable) is closed except for seeded first-party
|
|
85
|
+
producers. An unlisted producer that requests either is refused with a reason
|
|
86
|
+
in `.set.response`; nothing is silently downgraded.
|
|
87
|
+
|
|
88
|
+
## Message API
|
|
89
|
+
|
|
90
|
+
Every request gets a `.response` reply on the request's own context, so MQ
|
|
91
|
+
routing survives the round trip.
|
|
92
|
+
|
|
93
|
+
| Consumed | Reply |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `ovos.notification.api.set` `{notification}` | `.set.response` `{notification_id, status, reason?}` |
|
|
96
|
+
| `ovos.notification.api.remove` `{notification_id?, skill_id?, dismissed_by?}` | `.remove.response` `{notification_ids, status, reason?}` |
|
|
97
|
+
| `ovos.notification.api.get` `{notification_id}` | `.get.response` `{notification}` |
|
|
98
|
+
| `ovos.notification.api.list` `{skill_id?, state?, since?, node_id?, user_id?}` | `.list.response` `{notifications, states}` |
|
|
99
|
+
| `ovos.notification.api.snooze` `{notification_id, duration}` | `.snooze.response` `{notification_id, status, renotify_at?, reason?}` |
|
|
100
|
+
| `ovos.notification.api.interaction` `{notification_id, action_id, callback_data?}` | none; re-emitted for the producer, then dismissed if the action's `dismiss_on_activate` is true |
|
|
101
|
+
| `ovos.notification.api.sync.request` `{since?}` | `.sync.request.response` `{notifications, states, server_time}` plus one `notify` per entry |
|
|
102
|
+
| `ovos.notification.api.set.controlled` / `.remove.controlled` | deprecated wrappers; reply as `.set.response` / `.remove.response` |
|
|
103
|
+
|
|
104
|
+
| Emitted | When |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `ovos.notification.api.notify` `{notification}` | a `set` is accepted, a snooze elapses, `mycroft.ready`, `sync.request` |
|
|
107
|
+
| `ovos.notification.api.dismiss` `{notification_id, skill_id, scope, target, dismissed_by}` | a notification is removed, dismissed, or expires (`dismissed_by: "expired"`) |
|
|
108
|
+
| `ovos.notification.api.snoozed` `{notification_id, renotify_at, scope, target}` | a snooze is granted |
|
|
109
|
+
|
|
110
|
+
Behavior notes:
|
|
111
|
+
|
|
112
|
+
- `set` is an upsert. The same `skill_id` re-sending an existing
|
|
113
|
+
`notification_id` replaces it in place, resets it to ACTIVE, and re-emits
|
|
114
|
+
`notify`. A `notification_id` owned by a different `skill_id` is refused.
|
|
115
|
+
- `created_at` is stamped by the manager on first accept and preserved on
|
|
116
|
+
updates; `updated_at` is bumped on every change (upsert, snooze,
|
|
117
|
+
re-notify, dismissal, expiry, per-client dismissal) and rides on the wire
|
|
118
|
+
`Notification`. Every `since` filter (`list`, `sync.request`, and so the
|
|
119
|
+
HANA REST catch-up) compares against `updated_at`.
|
|
120
|
+
- Retention is a tombstone window, not history: DISMISSED and EXPIRED entries
|
|
121
|
+
stay for `retention_max_age_days` so a node that was offline learns on
|
|
122
|
+
catch-up that a notification was dismissed elsewhere, then they are pruned.
|
|
123
|
+
ACTIVE and SNOOZED entries are never pruned.
|
|
124
|
+
- A missing `session` is filled from `Message.context.session`. A CLIENT-scoped
|
|
125
|
+
notification with no `target` takes the requester's node identity from the
|
|
126
|
+
context (`context.node.node_id`, then `context.client_id`, then a non-local
|
|
127
|
+
`session.session_id`).
|
|
128
|
+
- `remove` needs `notification_id` (the producer is resolved from the stored
|
|
129
|
+
record; a `skill_id` that disagrees with it is refused) or `skill_id` alone
|
|
130
|
+
(remove everything that producer owns). Neither is refused. The requester
|
|
131
|
+
is `dismissed_by`, else `skill_id`, else the context's node/session
|
|
132
|
+
identity. A requester equal to the producer bypasses `removable_by_user`;
|
|
133
|
+
anyone else is a consumer subject to `removable_by_user` and the consumer
|
|
134
|
+
dismiss lists.
|
|
135
|
+
- `dismiss_policy=PER_CLIENT`: a consumer dismissal records that client and
|
|
136
|
+
leaves the shared state ACTIVE; `list` with `node_id` reports DISMISSED for
|
|
137
|
+
that client only. `SHARED` dismissals set DISMISSED for everyone.
|
|
138
|
+
- Legacy flat GUI-API payloads (`sender`, `text`, `action`, `type`, `style`)
|
|
139
|
+
are up-converted with a deprecation log.
|
|
140
|
+
|
|
141
|
+
## Testing
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install -e .[test]
|
|
145
|
+
pytest tests/
|
|
146
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# neon-phal-plugin-notifications
|
|
2
|
+
|
|
3
|
+
Notification Manager for Neon/OVOS, packaged as a PHAL plugin. It is the single
|
|
4
|
+
chokepoint for `ovos.notification.api.*` traffic on the hub messagebus: it
|
|
5
|
+
validates producer requests, owns notification state (active / snoozed /
|
|
6
|
+
dismissed / expired), persists that state across restarts, enforces emission
|
|
7
|
+
and dismissal policy, and emits `notify` / `dismiss` / `snoozed` events for
|
|
8
|
+
consumers (the Node app today, other plugins later).
|
|
9
|
+
|
|
10
|
+
The plugin never renders anything and never talks to MQ. Bus in, bus out.
|
|
11
|
+
Transport lives in `neon-messagebus-mq-connector` and `neon-hana`; rendering
|
|
12
|
+
lives in consumers.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
Notification data models come from `neon-data-models`. Until the next release
|
|
17
|
+
they live on the `FEAT_Notifications` branch; install that branch first:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install git+https://github.com/NeonGeckoCom/neon-data-models@FEAT_Notifications
|
|
21
|
+
pip install neon-phal-plugin-notifications
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
All keys live under the plugin's PHAL section. Every key is optional.
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
PHAL:
|
|
30
|
+
neon-phal-plugin-notifications:
|
|
31
|
+
store: json # persistence adapter; only "json" ships
|
|
32
|
+
store_path: null # override the JSON file path (default:
|
|
33
|
+
# $XDG_DATA_HOME/neon/neon-phal-plugin-notifications/notifications.json)
|
|
34
|
+
allow_global: # skill_ids permitted to use scope=GLOBAL
|
|
35
|
+
- skill-alerts.neongeckocom
|
|
36
|
+
allow_non_removable: # skill_ids permitted to set removable_by_user=false
|
|
37
|
+
- skill-alerts.neongeckocom
|
|
38
|
+
rate_limit: 30 # accepted `set` requests per producer per window
|
|
39
|
+
rate_window_seconds: 3600
|
|
40
|
+
consumers_allowed_to_dismiss: [] # empty/absent = every consumer may dismiss
|
|
41
|
+
consumers_blocked_from_dismiss: []
|
|
42
|
+
retention_max_age_days: 7 # tombstone window for dismissed/expired entries
|
|
43
|
+
tick_seconds: 60 # expiry + retention sweep interval
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Policy defaults are asymmetric on purpose: dismissal is open, high-impact
|
|
47
|
+
emission (GLOBAL scope, non-removable) is closed except for seeded first-party
|
|
48
|
+
producers. An unlisted producer that requests either is refused with a reason
|
|
49
|
+
in `.set.response`; nothing is silently downgraded.
|
|
50
|
+
|
|
51
|
+
## Message API
|
|
52
|
+
|
|
53
|
+
Every request gets a `.response` reply on the request's own context, so MQ
|
|
54
|
+
routing survives the round trip.
|
|
55
|
+
|
|
56
|
+
| Consumed | Reply |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `ovos.notification.api.set` `{notification}` | `.set.response` `{notification_id, status, reason?}` |
|
|
59
|
+
| `ovos.notification.api.remove` `{notification_id?, skill_id?, dismissed_by?}` | `.remove.response` `{notification_ids, status, reason?}` |
|
|
60
|
+
| `ovos.notification.api.get` `{notification_id}` | `.get.response` `{notification}` |
|
|
61
|
+
| `ovos.notification.api.list` `{skill_id?, state?, since?, node_id?, user_id?}` | `.list.response` `{notifications, states}` |
|
|
62
|
+
| `ovos.notification.api.snooze` `{notification_id, duration}` | `.snooze.response` `{notification_id, status, renotify_at?, reason?}` |
|
|
63
|
+
| `ovos.notification.api.interaction` `{notification_id, action_id, callback_data?}` | none; re-emitted for the producer, then dismissed if the action's `dismiss_on_activate` is true |
|
|
64
|
+
| `ovos.notification.api.sync.request` `{since?}` | `.sync.request.response` `{notifications, states, server_time}` plus one `notify` per entry |
|
|
65
|
+
| `ovos.notification.api.set.controlled` / `.remove.controlled` | deprecated wrappers; reply as `.set.response` / `.remove.response` |
|
|
66
|
+
|
|
67
|
+
| Emitted | When |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `ovos.notification.api.notify` `{notification}` | a `set` is accepted, a snooze elapses, `mycroft.ready`, `sync.request` |
|
|
70
|
+
| `ovos.notification.api.dismiss` `{notification_id, skill_id, scope, target, dismissed_by}` | a notification is removed, dismissed, or expires (`dismissed_by: "expired"`) |
|
|
71
|
+
| `ovos.notification.api.snoozed` `{notification_id, renotify_at, scope, target}` | a snooze is granted |
|
|
72
|
+
|
|
73
|
+
Behavior notes:
|
|
74
|
+
|
|
75
|
+
- `set` is an upsert. The same `skill_id` re-sending an existing
|
|
76
|
+
`notification_id` replaces it in place, resets it to ACTIVE, and re-emits
|
|
77
|
+
`notify`. A `notification_id` owned by a different `skill_id` is refused.
|
|
78
|
+
- `created_at` is stamped by the manager on first accept and preserved on
|
|
79
|
+
updates; `updated_at` is bumped on every change (upsert, snooze,
|
|
80
|
+
re-notify, dismissal, expiry, per-client dismissal) and rides on the wire
|
|
81
|
+
`Notification`. Every `since` filter (`list`, `sync.request`, and so the
|
|
82
|
+
HANA REST catch-up) compares against `updated_at`.
|
|
83
|
+
- Retention is a tombstone window, not history: DISMISSED and EXPIRED entries
|
|
84
|
+
stay for `retention_max_age_days` so a node that was offline learns on
|
|
85
|
+
catch-up that a notification was dismissed elsewhere, then they are pruned.
|
|
86
|
+
ACTIVE and SNOOZED entries are never pruned.
|
|
87
|
+
- A missing `session` is filled from `Message.context.session`. A CLIENT-scoped
|
|
88
|
+
notification with no `target` takes the requester's node identity from the
|
|
89
|
+
context (`context.node.node_id`, then `context.client_id`, then a non-local
|
|
90
|
+
`session.session_id`).
|
|
91
|
+
- `remove` needs `notification_id` (the producer is resolved from the stored
|
|
92
|
+
record; a `skill_id` that disagrees with it is refused) or `skill_id` alone
|
|
93
|
+
(remove everything that producer owns). Neither is refused. The requester
|
|
94
|
+
is `dismissed_by`, else `skill_id`, else the context's node/session
|
|
95
|
+
identity. A requester equal to the producer bypasses `removable_by_user`;
|
|
96
|
+
anyone else is a consumer subject to `removable_by_user` and the consumer
|
|
97
|
+
dismiss lists.
|
|
98
|
+
- `dismiss_policy=PER_CLIENT`: a consumer dismissal records that client and
|
|
99
|
+
leaves the shared state ACTIVE; `list` with `node_id` reports DISMISSED for
|
|
100
|
+
that client only. `SHARED` dismissals set DISMISSED for everyone.
|
|
101
|
+
- Legacy flat GUI-API payloads (`sender`, `text`, `action`, `type`, `style`)
|
|
102
|
+
are up-converted with a deprecation log.
|
|
103
|
+
|
|
104
|
+
## Testing
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e .[test]
|
|
108
|
+
pytest tests/
|
|
109
|
+
```
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System
|
|
2
|
+
# All trademark and other rights reserved by their respective owners
|
|
3
|
+
# Copyright 2008-2026 Neongecko.com Inc.
|
|
4
|
+
# BSD-3
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
# 1. Redistributions of source code must retain the above copyright notice,
|
|
8
|
+
# this list of conditions and the following disclaimer.
|
|
9
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
# and/or other materials provided with the distribution.
|
|
12
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
13
|
+
# contributors may be used to endorse or promote products derived from this
|
|
14
|
+
# software without specific prior written permission.
|
|
15
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
|
17
|
+
# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
18
|
+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
|
19
|
+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
20
|
+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
21
|
+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
|
22
|
+
# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
23
|
+
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
24
|
+
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
25
|
+
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
26
|
+
"""
|
|
27
|
+
Notification Manager PHAL plugin. Bus in, bus out: this module
|
|
28
|
+
adapts `ovos.notification.api.*` traffic to `NotificationManager`. It never
|
|
29
|
+
renders anything and never talks MQ directly.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from datetime import timedelta
|
|
33
|
+
from threading import Event
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
from ovos_bus_client.message import Message
|
|
37
|
+
from ovos_plugin_manager.templates.phal import PHALPlugin
|
|
38
|
+
from ovos_utils.log import LOG
|
|
39
|
+
from pydantic import BaseModel, ValidationError
|
|
40
|
+
|
|
41
|
+
from neon_data_models.models.api.messagebus.notifications import (
|
|
42
|
+
NotificationGetData, NotificationGetResponseData,
|
|
43
|
+
NotificationInteractionData, NotificationListData,
|
|
44
|
+
NotificationListResponseData, NotificationRemoveData,
|
|
45
|
+
NotificationRemoveResponseData, NotificationSetData,
|
|
46
|
+
NotificationSetResponseData, NotificationSnoozeData,
|
|
47
|
+
NotificationSnoozeResponseData, NotificationSyncRequestData)
|
|
48
|
+
from neon_data_models.models.base.notifications import Notification
|
|
49
|
+
|
|
50
|
+
from neon_phal_plugin_notifications.identity import is_forwarded
|
|
51
|
+
from neon_phal_plugin_notifications.legacy import (is_legacy_payload,
|
|
52
|
+
legacy_to_notification,
|
|
53
|
+
normalize_skill_id)
|
|
54
|
+
from neon_phal_plugin_notifications.manager import (
|
|
55
|
+
DEFAULT_RETENTION_MAX_AGE_DAYS, NotificationManager)
|
|
56
|
+
from neon_phal_plugin_notifications.policy import (ConsumerDismissPolicy,
|
|
57
|
+
EmissionPolicy)
|
|
58
|
+
from neon_phal_plugin_notifications.store import (JsonNotificationStore,
|
|
59
|
+
NotificationStore)
|
|
60
|
+
|
|
61
|
+
PLUGIN_NAME = "neon-phal-plugin-notifications"
|
|
62
|
+
API = "ovos.notification.api"
|
|
63
|
+
MSG_SET = f"{API}.set"
|
|
64
|
+
MSG_SET_CONTROLLED = f"{API}.set.controlled"
|
|
65
|
+
MSG_REMOVE = f"{API}.remove"
|
|
66
|
+
MSG_REMOVE_CONTROLLED = f"{API}.remove.controlled"
|
|
67
|
+
MSG_GET = f"{API}.get"
|
|
68
|
+
MSG_LIST = f"{API}.list"
|
|
69
|
+
MSG_SNOOZE = f"{API}.snooze"
|
|
70
|
+
MSG_INTERACTION = f"{API}.interaction"
|
|
71
|
+
MSG_SYNC = f"{API}.sync.request"
|
|
72
|
+
MSG_READY = "mycroft.ready"
|
|
73
|
+
RESPONSE_SUFFIX = ".response"
|
|
74
|
+
DEFAULT_STORE = "json"
|
|
75
|
+
DEFAULT_TICK_SECONDS = 60
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_store(config: dict, plugin_name: str = PLUGIN_NAME
|
|
79
|
+
) -> NotificationStore:
|
|
80
|
+
"""Select the persistence adapter by the `store` config key."""
|
|
81
|
+
kind = config.get("store", DEFAULT_STORE)
|
|
82
|
+
if kind != DEFAULT_STORE:
|
|
83
|
+
raise ValueError(f"unsupported notification store '{kind}'; only "
|
|
84
|
+
f"'{DEFAULT_STORE}' ships in this version")
|
|
85
|
+
return JsonNotificationStore(path=config.get("store_path"),
|
|
86
|
+
plugin_name=plugin_name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NotificationManagerPlugin(PHALPlugin):
|
|
90
|
+
def __init__(self, bus=None, config: Optional[dict] = None,
|
|
91
|
+
name: str = PLUGIN_NAME,
|
|
92
|
+
store: Optional[NotificationStore] = None):
|
|
93
|
+
# The base __init__ starts the thread; `run` blocks on `_ready` until
|
|
94
|
+
# the manager exists
|
|
95
|
+
self._ready = Event()
|
|
96
|
+
self._stop_event = Event()
|
|
97
|
+
super().__init__(bus=bus, name=name, config=config)
|
|
98
|
+
self.tick_seconds = self.config.get("tick_seconds", DEFAULT_TICK_SECONDS)
|
|
99
|
+
self.manager = self._build_manager(store or build_store(self.config,
|
|
100
|
+
name))
|
|
101
|
+
self.manager.restore()
|
|
102
|
+
self._register_handlers()
|
|
103
|
+
self._ready.set()
|
|
104
|
+
|
|
105
|
+
def _build_manager(self, store: NotificationStore) -> NotificationManager:
|
|
106
|
+
max_age = timedelta(days=self.config.get(
|
|
107
|
+
"retention_max_age_days", DEFAULT_RETENTION_MAX_AGE_DAYS))
|
|
108
|
+
return NotificationManager(
|
|
109
|
+
store=store, emit=self._emit_event,
|
|
110
|
+
emission_policy=EmissionPolicy.from_config(self.config),
|
|
111
|
+
dismiss_policy=ConsumerDismissPolicy.from_config(self.config),
|
|
112
|
+
retention_max_age=max_age)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def _handlers(self) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
MSG_SET: self.handle_set,
|
|
118
|
+
MSG_SET_CONTROLLED: self.handle_set_controlled,
|
|
119
|
+
MSG_REMOVE: self.handle_remove,
|
|
120
|
+
MSG_REMOVE_CONTROLLED: self.handle_remove_controlled,
|
|
121
|
+
MSG_GET: self.handle_get,
|
|
122
|
+
MSG_LIST: self.handle_list,
|
|
123
|
+
MSG_SNOOZE: self.handle_snooze,
|
|
124
|
+
MSG_INTERACTION: self.handle_interaction,
|
|
125
|
+
MSG_SYNC: self.handle_sync_request,
|
|
126
|
+
MSG_READY: self.handle_ready,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _register_handlers(self) -> None:
|
|
130
|
+
for msg_type, handler in self._handlers.items():
|
|
131
|
+
self.bus.on(msg_type, handler)
|
|
132
|
+
|
|
133
|
+
def run(self):
|
|
134
|
+
self._ready.wait()
|
|
135
|
+
while not self._stop_event.wait(self.tick_seconds):
|
|
136
|
+
self.manager.tick()
|
|
137
|
+
|
|
138
|
+
def shutdown(self):
|
|
139
|
+
self._stop_event.set()
|
|
140
|
+
for msg_type, handler in self._handlers.items():
|
|
141
|
+
self.bus.remove(msg_type, handler)
|
|
142
|
+
self.manager.shutdown()
|
|
143
|
+
super().shutdown()
|
|
144
|
+
|
|
145
|
+
# ---- producer handlers --------------------------------------------------
|
|
146
|
+
def handle_set(self, message: Message):
|
|
147
|
+
self._handle_set(message, controlled=False)
|
|
148
|
+
|
|
149
|
+
def handle_set_controlled(self, message: Message):
|
|
150
|
+
LOG.warning("%s is deprecated; send %s with `removable_by_user: "
|
|
151
|
+
"false`", MSG_SET_CONTROLLED, MSG_SET)
|
|
152
|
+
self._handle_set(message, controlled=True)
|
|
153
|
+
|
|
154
|
+
def _handle_set(self, message: Message, controlled: bool) -> None:
|
|
155
|
+
try:
|
|
156
|
+
notification = self._parse_notification(message, controlled)
|
|
157
|
+
except (ValidationError, ValueError, KeyError) as e:
|
|
158
|
+
self._reply(message, MSG_SET, NotificationSetResponseData(
|
|
159
|
+
notification_id=_requested_id(message.data), status="refused",
|
|
160
|
+
reason=f"invalid notification payload: {e}"))
|
|
161
|
+
return
|
|
162
|
+
self._reply(message, MSG_SET,
|
|
163
|
+
self.manager.set(notification, message.context))
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _parse_notification(message: Message, controlled: bool) -> Notification:
|
|
167
|
+
data = dict(message.data)
|
|
168
|
+
if is_legacy_payload(data):
|
|
169
|
+
return legacy_to_notification(data, removable_by_user=not controlled)
|
|
170
|
+
payload = normalize_skill_id(dict(data.get("notification") or {}))
|
|
171
|
+
if "session" not in payload and message.context.get("session"):
|
|
172
|
+
payload["session"] = message.context["session"]
|
|
173
|
+
if controlled:
|
|
174
|
+
payload["removable_by_user"] = False
|
|
175
|
+
return NotificationSetData(notification=payload).notification
|
|
176
|
+
|
|
177
|
+
def handle_remove(self, message: Message):
|
|
178
|
+
try:
|
|
179
|
+
data = NotificationRemoveData(**message.data)
|
|
180
|
+
except ValidationError as e:
|
|
181
|
+
self._reply(message, MSG_REMOVE, NotificationRemoveResponseData(
|
|
182
|
+
notification_ids=[], status="refused",
|
|
183
|
+
reason=f"invalid remove payload: {e}"))
|
|
184
|
+
return
|
|
185
|
+
self._reply(message, MSG_REMOVE,
|
|
186
|
+
self.manager.remove(data, message.context))
|
|
187
|
+
|
|
188
|
+
def handle_remove_controlled(self, message: Message):
|
|
189
|
+
LOG.warning("%s is deprecated; send %s", MSG_REMOVE_CONTROLLED,
|
|
190
|
+
MSG_REMOVE)
|
|
191
|
+
self.handle_remove(message)
|
|
192
|
+
|
|
193
|
+
def handle_get(self, message: Message):
|
|
194
|
+
try:
|
|
195
|
+
data = NotificationGetData(**message.data)
|
|
196
|
+
except ValidationError as e:
|
|
197
|
+
LOG.error("invalid get payload: %s", e)
|
|
198
|
+
self._reply(message, MSG_GET, NotificationGetResponseData())
|
|
199
|
+
return
|
|
200
|
+
self._reply(message, MSG_GET, self.manager.get(data.notification_id))
|
|
201
|
+
|
|
202
|
+
def handle_list(self, message: Message):
|
|
203
|
+
try:
|
|
204
|
+
data = NotificationListData(**message.data)
|
|
205
|
+
except ValidationError as e:
|
|
206
|
+
LOG.error("invalid list payload: %s", e)
|
|
207
|
+
self._reply(message, MSG_LIST, NotificationListResponseData(
|
|
208
|
+
notifications=[], states={}))
|
|
209
|
+
return
|
|
210
|
+
self._reply(message, MSG_LIST, self.manager.list(data))
|
|
211
|
+
|
|
212
|
+
def handle_snooze(self, message: Message):
|
|
213
|
+
try:
|
|
214
|
+
data = NotificationSnoozeData(**message.data)
|
|
215
|
+
except ValidationError as e:
|
|
216
|
+
self._reply(message, MSG_SNOOZE, NotificationSnoozeResponseData(
|
|
217
|
+
notification_id=_requested_id(message.data), status="refused",
|
|
218
|
+
reason=f"invalid snooze payload: {e}"))
|
|
219
|
+
return
|
|
220
|
+
self._reply(message, MSG_SNOOZE,
|
|
221
|
+
self.manager.snooze(data, message.context))
|
|
222
|
+
|
|
223
|
+
# ---- consumer handlers --------------------------------------------------
|
|
224
|
+
def handle_interaction(self, message: Message):
|
|
225
|
+
if is_forwarded(message.context):
|
|
226
|
+
return
|
|
227
|
+
try:
|
|
228
|
+
data = NotificationInteractionData(**message.data)
|
|
229
|
+
except ValidationError as e:
|
|
230
|
+
LOG.error("invalid interaction payload: %s", e)
|
|
231
|
+
return
|
|
232
|
+
self.manager.interact(data, message.context)
|
|
233
|
+
|
|
234
|
+
def handle_sync_request(self, message: Message):
|
|
235
|
+
try:
|
|
236
|
+
data = NotificationSyncRequestData(**message.data)
|
|
237
|
+
except ValidationError as e:
|
|
238
|
+
LOG.error("invalid sync payload: %s", e)
|
|
239
|
+
data = NotificationSyncRequestData()
|
|
240
|
+
self._reply(message, MSG_SYNC, self.manager.sync(data, message.context))
|
|
241
|
+
|
|
242
|
+
def handle_ready(self, message: Message):
|
|
243
|
+
count = self.manager.announce_active()
|
|
244
|
+
LOG.info("re-emitted notify for %s active notifications", count)
|
|
245
|
+
|
|
246
|
+
# ---- bus plumbing -------------------------------------------------------
|
|
247
|
+
def _reply(self, message: Message, request_type: str,
|
|
248
|
+
data: BaseModel) -> None:
|
|
249
|
+
self.bus.emit(message.reply(request_type + RESPONSE_SUFFIX,
|
|
250
|
+
data.model_dump()))
|
|
251
|
+
|
|
252
|
+
def _emit_event(self, msg_type: str, data: BaseModel, context: dict) -> None:
|
|
253
|
+
self.bus.emit(Message(msg_type, data.model_dump(), dict(context)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _requested_id(data: dict) -> str:
|
|
257
|
+
nested = data.get("notification") or {}
|
|
258
|
+
return str(nested.get("notification_id") or data.get("notification_id")
|
|
259
|
+
or "")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System
|
|
2
|
+
# All trademark and other rights reserved by their respective owners
|
|
3
|
+
# Copyright 2008-2026 Neongecko.com Inc.
|
|
4
|
+
# BSD-3
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
# 1. Redistributions of source code must retain the above copyright notice,
|
|
8
|
+
# this list of conditions and the following disclaimer.
|
|
9
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
# and/or other materials provided with the distribution.
|
|
12
|
+
# 3. Neither the name of the copyright holder nor the names of its
|
|
13
|
+
# contributors may be used to endorse or promote products derived from this
|
|
14
|
+
# software without specific prior written permission.
|
|
15
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
|
17
|
+
# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
18
|
+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
|
19
|
+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
20
|
+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
21
|
+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
|
22
|
+
# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
23
|
+
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
24
|
+
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
25
|
+
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
26
|
+
"""
|
|
27
|
+
Authorization decisions for consumer remove/dismiss requests.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from typing import Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from neon_data_models.enum import DismissPolicy
|
|
33
|
+
from neon_data_models.models.api.messagebus.notifications import \
|
|
34
|
+
NotificationRemoveData
|
|
35
|
+
|
|
36
|
+
from neon_phal_plugin_notifications import identity
|
|
37
|
+
from neon_phal_plugin_notifications.policy import ConsumerDismissPolicy
|
|
38
|
+
from neon_phal_plugin_notifications.store import StoredNotification
|
|
39
|
+
|
|
40
|
+
UNKNOWN_CONSUMER = "unknown"
|
|
41
|
+
|
|
42
|
+
# (refusal_reason, dismissed_by, per_client_id); reason None means allowed
|
|
43
|
+
RemovalVerdict = Tuple[Optional[str], Optional[str], Optional[str]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def requester_identity(data: NotificationRemoveData,
|
|
47
|
+
context: dict) -> Optional[str]:
|
|
48
|
+
"""
|
|
49
|
+
Who is asking: `data.dismissed_by` (HANA REST dismiss), else the payload
|
|
50
|
+
`skill_id` (a producer removing its own), else the context's consumer.
|
|
51
|
+
"""
|
|
52
|
+
return (data.dismissed_by or data.skill_id
|
|
53
|
+
or identity.consumer_id(context))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def per_client_id(record: StoredNotification,
|
|
57
|
+
consumer: Optional[str]) -> Optional[str]:
|
|
58
|
+
"""Client to record a dismissal against, for PER_CLIENT notifications."""
|
|
59
|
+
if record.notification.dismiss_policy == DismissPolicy.PER_CLIENT:
|
|
60
|
+
return consumer
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def authorize_removal(record: StoredNotification, data: NotificationRemoveData,
|
|
65
|
+
context: dict, policy: ConsumerDismissPolicy
|
|
66
|
+
) -> RemovalVerdict:
|
|
67
|
+
"""A requester identified as the producer bypasses `removable_by_user`."""
|
|
68
|
+
consumer = requester_identity(data, context)
|
|
69
|
+
if consumer == record.skill_id:
|
|
70
|
+
return None, record.skill_id, None
|
|
71
|
+
allowed, reason = policy.check(consumer)
|
|
72
|
+
if not allowed:
|
|
73
|
+
return reason, None, None
|
|
74
|
+
if not record.notification.removable_by_user:
|
|
75
|
+
return (f"notification '{record.notification_id}' is only removable "
|
|
76
|
+
f"by its producer '{record.skill_id}'"), None, None
|
|
77
|
+
client_id = per_client_id(record, consumer)
|
|
78
|
+
if (record.notification.dismiss_policy == DismissPolicy.PER_CLIENT
|
|
79
|
+
and client_id is None):
|
|
80
|
+
return ("PER_CLIENT dismissal requires a client identity in the "
|
|
81
|
+
"message context"), None, None
|
|
82
|
+
return None, consumer or UNKNOWN_CONSUMER, client_id
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def snooze_refusal(record: Optional[StoredNotification],
|
|
86
|
+
duration: int) -> Optional[str]:
|
|
87
|
+
if record is None:
|
|
88
|
+
return "unknown notification"
|
|
89
|
+
if not record.is_live():
|
|
90
|
+
return f"notification is {record.state.value}"
|
|
91
|
+
if duration <= 0:
|
|
92
|
+
return "duration must be a positive number of seconds"
|
|
93
|
+
return None
|