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.
Files changed (25) hide show
  1. neon_phal_plugin_notifications-0.1.0/LICENSE.md +21 -0
  2. neon_phal_plugin_notifications-0.1.0/PKG-INFO +146 -0
  3. neon_phal_plugin_notifications-0.1.0/README.md +109 -0
  4. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/__init__.py +259 -0
  5. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/authorization.py +93 -0
  6. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/identity.py +70 -0
  7. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/legacy.py +101 -0
  8. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/manager.py +334 -0
  9. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/policy.py +144 -0
  10. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/scheduler.py +72 -0
  11. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications/store.py +260 -0
  12. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/PKG-INFO +146 -0
  13. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/SOURCES.txt +23 -0
  14. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/dependency_links.txt +1 -0
  15. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/entry_points.txt +2 -0
  16. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/requires.txt +14 -0
  17. neon_phal_plugin_notifications-0.1.0/neon_phal_plugin_notifications.egg-info/top_level.txt +1 -0
  18. neon_phal_plugin_notifications-0.1.0/pyproject.toml +79 -0
  19. neon_phal_plugin_notifications-0.1.0/setup.cfg +4 -0
  20. neon_phal_plugin_notifications-0.1.0/tests/test_legacy.py +46 -0
  21. neon_phal_plugin_notifications-0.1.0/tests/test_manager.py +393 -0
  22. neon_phal_plugin_notifications-0.1.0/tests/test_plugin.py +288 -0
  23. neon_phal_plugin_notifications-0.1.0/tests/test_policy.py +78 -0
  24. neon_phal_plugin_notifications-0.1.0/tests/test_scheduler.py +27 -0
  25. 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