Skip to content

Commit e205ecb

Browse files
authored
Add support for sequence ID and clear/delete of notifications (#150)
1 parent 981a53a commit e205ecb

8 files changed

Lines changed: 190 additions & 3 deletions

File tree

src/aiontfy/ntfy.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,54 @@ async def publish(
132132
await self._request("POST", self.url, json=message.to_dict())
133133
)
134134

135+
async def clear(self, topic: str, sequence_id: str) -> Notification:
136+
"""Clear a notification.
137+
138+
Clearing a notification means marking it as read and dismissing it from the notification drawer.
139+
140+
Parameters
141+
----------
142+
topic: str
143+
The topic from which to clear a notification.
144+
sequence_id: str
145+
The sequence-ID to identify the notification to be cleared.
146+
147+
Raises
148+
------
149+
NtfyTimeoutError
150+
If a timeout occurs during the request.
151+
NtfyConnectionError
152+
If a client error occurs during the request.
153+
"""
154+
155+
url = self.url / topic / sequence_id / "clear"
156+
157+
return Notification.from_json(await self._request("PUT", url))
158+
159+
async def delete(self, topic: str, sequence_id: str) -> Notification:
160+
"""Delete a notification.
161+
162+
Deleting a notification means removing it from the notification drawer and from the client's database.
163+
164+
Parameters
165+
----------
166+
topic: str
167+
The topic from which to delete a notification.
168+
sequence_id: str
169+
The sequence-ID to identify the notification to be deleted.
170+
171+
Raises
172+
------
173+
NtfyTimeoutError
174+
If a timeout occurs during the request.
175+
NtfyConnectionError
176+
If a client error occurs during the request.
177+
"""
178+
179+
url = self.url / topic / sequence_id
180+
181+
return Notification.from_json(await self._request("DELETE", url))
182+
135183
async def subscribe( # noqa: PLR0913
136184
self,
137185
topics: list[str],

src/aiontfy/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ class Message(DataClassORJSONMixin):
159159
E-mail address for e-mail notifications.
160160
call : str or None, optional
161161
Phone number to use for voice call.
162+
sequence_id: str or None, optional
163+
Sequence ID for updating/deleting notifications
162164
163165
"""
164166

@@ -184,6 +186,7 @@ class Message(DataClassORJSONMixin):
184186
delay: str | None = None
185187
email: str | None = None
186188
call: str | None = None
189+
sequence_id: str | None = None
187190

188191
def __post_init__(self) -> None:
189192
"""Post-initialization processing to validate attributes.
@@ -217,6 +220,7 @@ def to_x_headers(self) -> dict[str, str]:
217220
"delay": "X-Delay",
218221
"email": "X-Email",
219222
"call": "X-Call",
223+
"sequence_id": "X-Sequence-ID",
220224
}
221225
data = self.to_dict()
222226

@@ -264,6 +268,8 @@ class Event(StrEnum):
264268
KEEPALIVE = "keepalive"
265269
MESSAGE = "message"
266270
POLL_REQUEST = "poll_request"
271+
MESSAGE_CLEAR = "message_clear"
272+
MESSAGE_DELETE = "message_delete"
267273

268274

269275
def timestamp(ts: int) -> datetime:
@@ -310,6 +316,7 @@ class Notification(DataClassORJSONMixin):
310316
)
311317
attachment: Attachment | None = None
312318
content_type: str | None = None
319+
sequence_id: str | None = None
313320

314321

315322
@dataclass(kw_only=True, frozen=True)

test.ipynb

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
"cells": [
33
{
44
"cell_type": "code",
5-
"execution_count": 3,
5+
"execution_count": null,
66
"metadata": {},
77
"outputs": [
88
{
99
"name": "stdout",
1010
"output_type": "stream",
1111
"text": [
12-
"{'id': 'wUIvpVrClRN4', 'time': 1736379310, 'expires': 1736422510, 'event': 'message', 'topic': 'eratzartehzqwrtzq45634zhsfg', 'title': 'Title', 'message': 'test', 'priority': 5, 'tags': ['octopus'], 'click': 'https://habitica.com/', 'icon': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/shop_defensiveStance.png', 'content_type': 'text/markdown'}\n"
12+
"Notification(id='9kbiJdRntcGB', time=datetime.datetime(2026, 1, 20, 17, 43, 51, tzinfo=datetime.timezone.utc), expires=datetime.datetime(2026, 1, 21, 5, 43, 51, tzinfo=datetime.timezone.utc), event=<Event.MESSAGE: 'message'>, topic='eratzartehzqwrtzq45634zhsfg', message='test', title='Title', tags=[], priority=None, click=URL('https://habitica.com/'), icon=None, actions=[], attachment=None, content_type=None, sequence_id='Mc3otamDNcpJ')\n"
1313
]
1414
}
1515
],
@@ -30,9 +30,40 @@
3030
" priority=5,\n",
3131
" tags=[\"octopus\"],\n",
3232
" markdown=True,\n",
33+
" sequence_id=\"Mc3otamDNcpJ\",\n",
3334
" )\n",
3435
" print(await ntfy.publish(message))"
3536
]
37+
},
38+
{
39+
"cell_type": "code",
40+
"execution_count": null,
41+
"metadata": {},
42+
"outputs": [
43+
{
44+
"name": "stdout",
45+
"output_type": "stream",
46+
"text": [
47+
"Notification(id='ignYrqz75Xcc', time=datetime.datetime(2026, 1, 20, 17, 44, 3, tzinfo=datetime.timezone.utc), expires=datetime.datetime(2026, 1, 21, 5, 44, 3, tzinfo=datetime.timezone.utc), event=<Event.MESSAGE_DELETE: 'message_delete'>, topic='eratzartehzqwrtzq45634zhsfg', message=None, title=None, tags=[], priority=None, click=None, icon=None, actions=[], attachment=None, content_type=None, sequence_id='Mc3otamDNcpJ')\n"
48+
]
49+
}
50+
],
51+
"source": [
52+
"async with ClientSession() as session:\n",
53+
" ntfy = Ntfy(\"https://ntfy.sh\", session)\n",
54+
" print(await ntfy.clear(\"eratzartehzqwrtzq45634zhsfg\", \"Mc3otamDNcpJ\"))"
55+
]
56+
},
57+
{
58+
"cell_type": "code",
59+
"execution_count": null,
60+
"metadata": {},
61+
"outputs": [],
62+
"source": [
63+
"async with ClientSession() as session:\n",
64+
" ntfy = Ntfy(\"https://ntfy.sh\", session)\n",
65+
" print(await ntfy.delete(\"eratzartehzqwrtzq45634zhsfg\", \"Mc3otamDNcpJ\"))"
66+
]
3667
}
3768
],
3869
"metadata": {

tests/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
from aiohttp.web_ws import WebSocketResponse
1010
import pytest
1111

12-
MSG = """{"id": "h6Y2hKA5sy0U", "time": 1743184726, "expires": 1743227926, "event": "message", "topic": "test1", "message": "Hello", "title": "Title", "tags": ["octopus"], "priority": 3, "click": "https://example.com/", "icon": "https://example.com/icon.png", "actions": [], "attachment": null}"""
12+
MSG = """{"id": "h6Y2hKA5sy0U", "time": 1743184726, "expires": 1743227926, "event": "message", "topic": "test1", "message": "Hello", "title": "Title", "tags": ["octopus"], "priority": 3, "click": "https://example.com/", "icon": "https://example.com/icon.png", "actions": [], "attachment": null, "sequence_id": "Mc3otamDNcpJ"}"""
1313
MSG_2 = """{"id": "h6Y2hKA5sy0U", "time": 1743184726, "expires": 1743227926, "event": "message", "topic": "test2", "message": "World", "title": "Title", "tags": ["octopus"], "priority": 5, "click": "https://example.com/", "actions": [], "attachment": null}"""
1414

15+
MSG_CLEAR = """{"id": "h6Y2hKA5sy0U", "time": 1743184726, "expires": 1743227926, "event": "message_clear", "topic": "test1", "message": "Hello", "title": "Title", "tags": ["octopus"], "priority": 3, "click": "https://example.com/", "icon": "https://example.com/icon.png", "actions": [], "attachment": null, "sequence_id": "Mc3otamDNcpJ"}"""
16+
MSG_DELETE = """{"id": "h6Y2hKA5sy0U", "time": 1743184726, "expires": 1743227926, "event": "message_delete", "topic": "test1", "message": "Hello", "title": "Title", "tags": ["octopus"], "priority": 3, "click": "https://example.com/", "icon": "https://example.com/icon.png", "actions": [], "attachment": null, "sequence_id": "Mc3otamDNcpJ"}"""
17+
1518

1619
@pytest.fixture
1720
def mock_session() -> Generator[AsyncMock]:

tests/test_clear.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests for aiontfy."""
2+
3+
from datetime import UTC, datetime
4+
from unittest.mock import AsyncMock
5+
6+
from yarl import URL
7+
8+
from aiontfy import Event, Notification, Ntfy
9+
10+
from .conftest import MSG_CLEAR
11+
12+
13+
async def test_clear_message(mock_session: AsyncMock) -> None:
14+
"""Test clearing a message to ntfy."""
15+
16+
mock_session.request.return_value.__aenter__.return_value.status = 200
17+
mock_session.request.return_value.__aenter__.return_value.text.return_value = (
18+
MSG_CLEAR
19+
)
20+
21+
ntfy = Ntfy("http://example.com", mock_session)
22+
23+
resp = await ntfy.clear("mytopic", "Mc3otamDNcpJ")
24+
25+
assert resp == Notification(
26+
id="h6Y2hKA5sy0U",
27+
time=datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC),
28+
expires=datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC),
29+
event=Event.MESSAGE_CLEAR,
30+
topic="test1",
31+
message="Hello",
32+
title="Title",
33+
tags=["octopus"],
34+
priority=3,
35+
click=URL("https://example.com/"),
36+
icon=URL("https://example.com/icon.png"),
37+
actions=[],
38+
attachment=None,
39+
sequence_id="Mc3otamDNcpJ",
40+
)
41+
42+
mock_session.request.assert_called_once_with(
43+
"PUT",
44+
URL("http://example.com/mytopic/Mc3otamDNcpJ/clear"),
45+
)

tests/test_delete.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests for aiontfy."""
2+
3+
from datetime import UTC, datetime
4+
from unittest.mock import AsyncMock
5+
6+
from yarl import URL
7+
8+
from aiontfy import Event, Notification, Ntfy
9+
10+
from .conftest import MSG_DELETE
11+
12+
13+
async def test_delete_message(mock_session: AsyncMock) -> None:
14+
"""Test deleting a message to ntfy."""
15+
16+
mock_session.request.return_value.__aenter__.return_value.status = 200
17+
mock_session.request.return_value.__aenter__.return_value.text.return_value = (
18+
MSG_DELETE
19+
)
20+
21+
ntfy = Ntfy("http://example.com", mock_session)
22+
23+
resp = await ntfy.delete("mytopic", "Mc3otamDNcpJ")
24+
25+
assert resp == Notification(
26+
id="h6Y2hKA5sy0U",
27+
time=datetime(2025, 3, 28, 17, 58, 46, tzinfo=UTC),
28+
expires=datetime(2025, 3, 29, 5, 58, 46, tzinfo=UTC),
29+
event=Event.MESSAGE_DELETE,
30+
topic="test1",
31+
message="Hello",
32+
title="Title",
33+
tags=["octopus"],
34+
priority=3,
35+
click=URL("https://example.com/"),
36+
icon=URL("https://example.com/icon.png"),
37+
actions=[],
38+
attachment=None,
39+
sequence_id="Mc3otamDNcpJ",
40+
)
41+
42+
mock_session.request.assert_called_once_with(
43+
"DELETE",
44+
URL("http://example.com/mytopic/Mc3otamDNcpJ"),
45+
)

tests/test_publish.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ async def test_publish_message(mock_session: AsyncMock) -> None:
3838
"delay": None,
3939
"email": None,
4040
"call": None,
41+
"sequence_id": None,
4142
},
4243
)
4344

@@ -72,6 +73,7 @@ async def test_publish_basic_auth(mock_session: AsyncMock) -> None:
7273
"delay": None,
7374
"email": None,
7475
"call": None,
76+
"sequence_id": None,
7577
},
7678
)
7779

@@ -106,5 +108,6 @@ async def test_publish_bearer_auth(mock_session: AsyncMock) -> None:
106108
"delay": None,
107109
"email": None,
108110
"call": None,
111+
"sequence_id": None,
109112
},
110113
)

tests/test_subscribe.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ async def test_subscribe_success(mock_ws: AsyncMock) -> None:
4545
icon=URL("https://example.com/icon.png"),
4646
actions=[],
4747
attachment=None,
48+
sequence_id="Mc3otamDNcpJ",
4849
)
4950
)
5051

@@ -112,6 +113,7 @@ async def test_subscribe_with_filters(mock_ws: AsyncMock) -> None:
112113
icon=URL("https://example.com/icon.png"),
113114
actions=[],
114115
attachment=None,
116+
sequence_id="Mc3otamDNcpJ",
115117
)
116118
)
117119

@@ -150,6 +152,7 @@ async def test_subscribe_multiple_topics(mock_ws: AsyncMock) -> None:
150152
icon=URL("https://example.com/icon.png"),
151153
actions=[],
152154
attachment=None,
155+
sequence_id="Mc3otamDNcpJ",
153156
)
154157
)
155158
callback_mock.assert_any_call(
@@ -244,6 +247,7 @@ async def test_subscribe_basic_auth(mock_ws: AsyncMock) -> None:
244247
icon=URL("https://example.com/icon.png"),
245248
actions=[],
246249
attachment=None,
250+
sequence_id="Mc3otamDNcpJ",
247251
)
248252
)
249253

@@ -278,5 +282,6 @@ async def test_subscribe_bearer_auth(mock_ws: AsyncMock) -> None:
278282
icon=URL("https://example.com/icon.png"),
279283
actions=[],
280284
attachment=None,
285+
sequence_id="Mc3otamDNcpJ",
281286
)
282287
)

0 commit comments

Comments
 (0)