some random stuff. caelestia incoming

This commit is contained in:
voidarclabs
2025-08-21 17:40:48 +01:00
parent 12df9a0b6e
commit 1cc414a96a
1308 changed files with 217219 additions and 8 deletions

View File

@@ -0,0 +1,51 @@
"""Async ntfy client library."""
from .ntfy import Ntfy
from .types import (
Account,
AccountBilling,
AccountLimits,
AccountStats,
AccountTier,
AccountTokenResponse,
Attachment,
BroadcastAction,
DeleteAfter,
Event,
Everyone,
HttpAction,
Message,
Notification,
Priority,
Reservation,
Response,
Sound,
Stats,
ViewAction,
)
__version__ = "0.0.0"
__all__ = [
"Account",
"AccountBilling",
"AccountLimits",
"AccountStats",
"AccountTier",
"AccountTokenResponse",
"Attachment",
"BroadcastAction",
"DeleteAfter",
"Event",
"Everyone",
"HttpAction",
"Message",
"Notification",
"Ntfy",
"Priority",
"Reservation",
"Response",
"Sound",
"Stats",
"ViewAction",
]

View File

@@ -0,0 +1,6 @@
"""Constants for aiontfy."""
__version__ = "0.5.4"
MIN_PRIORITY = 1
MAX_PRIORITY = 5

View File

@@ -0,0 +1,496 @@
"""Exceptions for aiontfy."""
class NtfyException(Exception): # noqa: N818
"""Base ntfy exception."""
class NtfyHTTPError(NtfyException):
"""Base class for HTTP errors."""
def __init__(
self, code: int, http: int, error: str, link: str | None = None
) -> None:
"""
Initialize the exception with a code, HTTP status, error message, and link.
Parameters
----------
code : int
The error code.
http : int
The HTTP status code.
error : str
The error message.
link : str, optional
A link to more information about the error.
"""
self.code = code
self.http = http
self.error = error
self.link = link
super().__init__(self.error)
class NtfyConnectionError(NtfyException):
"""Connection error."""
class NtfyTimeoutError(NtfyException):
"""Timeout error."""
class NtfyUnknownError(NtfyException):
"""Unexpected HTTP errors."""
class NtfyBadRequestError(NtfyHTTPError):
"""400 Bad Request."""
class NtfyUnauthorizedError(NtfyHTTPError):
"""401 Unauthorized."""
class NtfyForbiddenError(NtfyHTTPError):
"""403 Forbidden."""
class NtfyNotFoundError(NtfyHTTPError):
"""404 Not Found."""
class NtfyConflictError(NtfyHTTPError):
"""409 Conflict."""
class NtfyGoneError(NtfyHTTPError):
"""410 Gone."""
class NtfyRequestEntityTooLargeError(NtfyHTTPError):
"""413 Request Entity Too Large."""
class NtfyTooManyRequestsError(NtfyHTTPError):
"""429 Too Many Requests."""
class NtfyInternalServerError(NtfyHTTPError):
"""500 Internal Server Error."""
class NtfyInsufficientStorageError(NtfyHTTPError):
"""507 Insufficient Storage."""
class NtfyBadRequestEmailDisabledError(NtfyBadRequestError):
"""40001 E-mail notifications are not enabled."""
class NtfyBadRequestDelayNoCacheError(NtfyBadRequestError):
"""40002 Cannot disable cache for delayed message."""
class NtfyBadRequestDelayNoEmailError(NtfyBadRequestError):
"""40003 Delayed e-mail notifications are not supported."""
class NtfyBadRequestDelayCannotParseError(NtfyBadRequestError):
"""40004 Invalid delay parameter: unable to parse delay."""
class NtfyBadRequestDelayTooSmallError(NtfyBadRequestError):
"""40005 Invalid delay parameter: too small."""
class NtfyBadRequestDelayTooLargeError(NtfyBadRequestError):
"""40006 Invalid delay parameter: too large."""
class NtfyBadRequestPriorityInvalidError(NtfyBadRequestError):
"""40007 Invalid priority parameter."""
class NtfyBadRequestSinceInvalidError(NtfyBadRequestError):
"""40008 Invalid since parameter."""
class NtfyBadRequestTopicInvalidError(NtfyBadRequestError):
"""40009 Invalid request: topic invalid."""
class NtfyBadRequestTopicDisallowedError(NtfyBadRequestError):
"""40010 Invalid request: topic name is not allowed."""
class NtfyBadRequestMessageNotUTF8Error(NtfyBadRequestError):
"""40011 Invalid request: message must be UTF-8 encoded."""
class NtfyBadRequestAttachmentURLInvalidError(NtfyBadRequestError):
"""40013 Invalid request: attachment URL is invalid."""
class NtfyBadRequestAttachmentsDisallowedError(NtfyBadRequestError):
"""40014 Invalid request: attachments not allowed."""
class NtfyBadRequestAttachmentsExpiryBeforeDeliveryError(NtfyBadRequestError):
"""40015 Invalid request: attachment expiry before delayed delivery date."""
class NtfyBadRequestWebSocketsUpgradeHeaderMissingError(NtfyBadRequestError):
"""40016 Invalid request: client not using the websocket protocol."""
class NtfyBadRequestMessageJSONInvalidError(NtfyBadRequestError):
"""40017 Invalid request: request body must be message JSON."""
class NtfyBadRequestActionsInvalidError(NtfyBadRequestError):
"""40018 Invalid request: actions invalid."""
class NtfyBadRequestMatrixMessageInvalidError(NtfyBadRequestError):
"""40019 Invalid request: Matrix JSON invalid."""
class NtfyBadRequestIconURLInvalidError(NtfyBadRequestError):
"""40021 Invalid request: icon URL is invalid."""
class NtfyBadRequestSignupNotEnabledError(NtfyBadRequestError):
"""40022 Invalid request: signup not enabled."""
class NtfyBadRequestNoTokenProvidedError(NtfyBadRequestError):
"""40023 Invalid request: no token provided."""
class NtfyBadRequestJSONInvalidError(NtfyBadRequestError):
"""40024 Invalid request: request body must be valid JSON."""
class NtfyBadRequestPermissionInvalidError(NtfyBadRequestError):
"""40025 Invalid request: incorrect permission string."""
class NtfyBadRequestIncorrectwordConfirmationError(NtfyBadRequestError):
"""40026 Invalid request: word confirmation is not correct."""
class NtfyBadRequestNotAPaidUserError(NtfyBadRequestError):
"""40027 Invalid request: not a paid user."""
class NtfyBadRequestBillingRequestInvalidError(NtfyBadRequestError):
"""40028 Invalid request: not a valid billing request."""
class NtfyBadRequestBillingSubscriptionExistsError(NtfyBadRequestError):
"""40029 Invalid request: billing subscription already exists."""
class NtfyBadRequestTierInvalidError(NtfyBadRequestError):
"""40030 Invalid request: tier does not exist."""
class NtfyBadRequestUserNotFoundError(NtfyBadRequestError):
"""40031 Invalid request: user does not exist."""
class NtfyBadRequestPhoneCallsDisabledError(NtfyBadRequestError):
"""40032 Invalid request: calling is disabled."""
class NtfyBadRequestPhoneNumberInvalidError(NtfyBadRequestError):
"""40033 Invalid request: phone number invalid."""
class NtfyBadRequestPhoneNumberNotVerifiedError(NtfyBadRequestError):
"""40034 Invalid request: phone number not verified."""
class NtfyBadRequestAnonymousCallsNotAllowedError(NtfyBadRequestError):
"""40035 Invalid request: anonymous phone calls are not allowed."""
class NtfyBadRequestPhoneNumberVerifyChannelInvalidError(NtfyBadRequestError):
"""40036 Invalid request: verification channel must be 'sms' or 'call'."""
class NtfyBadRequestDelayNoCallError(NtfyBadRequestError):
"""40037 Invalid request: delayed call notifications are not supported."""
class NtfyBadRequestWebPushSubscriptionInvalidError(NtfyBadRequestError):
"""40038 Invalid request: web push payload malformed."""
class NtfyBadRequestWebPushEndpointUnknownError(NtfyBadRequestError):
"""40039 Invalid request: web push endpoint unknown."""
class NtfyBadRequestWebPushTopicCountTooHighError(NtfyBadRequestError):
"""40040 Invalid request: too many web push topic subscriptions."""
class NtfyBadRequestTemplateMessageTooLargeError(NtfyBadRequestError):
"""40041 Invalid request: message or title is too large after replacing template."""
class NtfyBadRequestTemplateMessageNotJSONError(NtfyBadRequestError):
"""40042 Invalid request: message body must be JSON if templating is enabled."""
class NtfyBadRequestTemplateInvalidError(NtfyBadRequestError):
"""40043 Invalid request: could not parse template."""
class NtfyBadRequestTemplateDisallowedFunctionCallsError(NtfyBadRequestError):
"""40044 Invalid request: template contains disallowed function calls."""
class NtfyBadRequestTemplateExecuteFailedError(NtfyBadRequestError):
"""40045 Invalid request: template execution failed."""
class NtfyBadRequestInvalidUsernameError(NtfyBadRequestError):
"""40046 Invalid request: invalid username."""
# 404 Not Found Errors
class NtfyNotFoundPageError(NtfyNotFoundError):
"""40401 Page not found."""
# 401 Unauthorized Errors
class NtfyUnauthorizedAuthenticationError(NtfyUnauthorizedError):
"""40101 Unauthorized."""
# 403 Forbidden Errors
class NtfyForbiddenAccessError(NtfyForbiddenError):
"""40301 Forbidden."""
# 409 Conflict Errors
class NtfyConflictUserExistsError(NtfyConflictError):
"""40901 Conflict: user already exists."""
class NtfyConflictTopicReservedError(NtfyConflictError):
"""40902 Conflict: access control entry for topic or topic pattern already exists."""
class NtfyConflictSubscriptionExistsError(NtfyConflictError):
"""40903 Conflict: topic subscription already exists."""
class NtfyConflictPhoneNumberExistsError(NtfyConflictError):
"""40904 Conflict: phone number already exists."""
# 410 Gone Errors
class NtfyGonePhoneVerificationExpiredError(NtfyGoneError):
"""41001 Phone number verification expired or does not exist."""
# 413 Request Entity Too Large Errors
class NtfyRequestEntityTooLargeAttachmentError(NtfyRequestEntityTooLargeError):
"""41301 Attachment too large, or bandwidth limit reached."""
class NtfyRequestEntityTooLargeMatrixRequestError(NtfyRequestEntityTooLargeError):
"""41302 Matrix request is larger than the max allowed length."""
class NtfyRequestEntityTooLargeJSONBodyError(NtfyRequestEntityTooLargeError):
"""41303 JSON body too large."""
# 429 Too Many Requests Errors
class NtfyTooManyRequestsLimitRequestsError(NtfyTooManyRequestsError):
"""42901 Limit reached: too many requests."""
class NtfyTooManyRequestsLimitEmailsError(NtfyTooManyRequestsError):
"""42902 Limit reached: too many emails."""
class NtfyTooManyRequestsLimitSubscriptionsError(NtfyTooManyRequestsError):
"""42903 Limit reached: too many active subscriptions."""
class NtfyTooManyRequestsLimitTotalTopicsError(NtfyTooManyRequestsError):
"""42904 Limit reached: the total number of topics on the server has been reached."""
class NtfyTooManyRequestsLimitAttachmentBandwidthError(NtfyTooManyRequestsError):
"""42905 Limit reached: daily bandwidth reached."""
class NtfyTooManyRequestsLimitAccountCreationError(NtfyTooManyRequestsError):
"""42906 Limit reached: too many accounts created."""
class NtfyTooManyRequestsLimitReservationsError(NtfyTooManyRequestsError):
"""42907 Limit reached: too many topic reservations for this user."""
class NtfyTooManyRequestsLimitMessagesError(NtfyTooManyRequestsError):
"""42908 Limit reached: daily message quota reached."""
class NtfyTooManyRequestsLimitAuthFailureError(NtfyTooManyRequestsError):
"""42909 Limit reached: too many auth failures."""
class NtfyTooManyRequestsLimitCallsError(NtfyTooManyRequestsError):
"""42910 Limit reached: daily phone call quota reached."""
# 500 Internal Server Error
class NtfyInternalErrorInvalidPathError(NtfyInternalServerError):
"""50002 Internal server error: invalid path."""
class NtfyInternalErrorMissingBaseURLError(NtfyInternalServerError):
"""50003 Internal server error: base-url must be configured for this feature."""
class NtfyInternalErrorWebPushUnableToPublishError(NtfyInternalServerError):
"""50004 Internal server error: unable to publish web push message."""
# 507 Insufficient Storage Errors
class NtfyInsufficientStorageUnifiedPushError(NtfyInsufficientStorageError):
"""50701 Cannot publish to UnifiedPush topic without previously active subscriber."""
def raise_http_error(code: int, http: int, error: str, link: str | None = None) -> None:
"""Raise an appropriate HTTP error based on the provided error code.
Parameters
----------
code : int
The specific error code to raise.
http : int
The HTTP status code associated with the error.
error : str
A description of the error.
link : str, optional
A URL link providing more information about the error.
Raises
------
NtfyBadRequestError
If the error code is 400.
NtfyUnauthorizedError
If the error code is 401.
NtfyForbiddenError
If the error code is 403.
NtfyNotFoundError
If the error code is 404.
NtfyConflictError
If the error code is 409.
NtfyGoneError
If the error code is 410.
NtfyRequestEntityTooLargeError
If the error code is 413.
NtfyTooManyRequestsError
If the error code is 429.
NtfyInternalServerError
If the error code is 500.
NtfyInsufficientStorageError
If the error code is 507.
NtfyUnknownError
If the error code is not recognized.
"""
error_map = {
400: NtfyBadRequestError,
401: NtfyUnauthorizedError,
403: NtfyForbiddenError,
404: NtfyNotFoundError,
409: NtfyConflictError,
410: NtfyGoneError,
413: NtfyRequestEntityTooLargeError,
429: NtfyTooManyRequestsError,
500: NtfyInternalServerError,
507: NtfyInsufficientStorageError,
40001: NtfyBadRequestEmailDisabledError,
40002: NtfyBadRequestDelayNoCacheError,
40003: NtfyBadRequestDelayNoEmailError,
40004: NtfyBadRequestDelayCannotParseError,
40005: NtfyBadRequestDelayTooSmallError,
40006: NtfyBadRequestDelayTooLargeError,
40007: NtfyBadRequestPriorityInvalidError,
40008: NtfyBadRequestSinceInvalidError,
40009: NtfyBadRequestTopicInvalidError,
40010: NtfyBadRequestTopicDisallowedError,
40011: NtfyBadRequestMessageNotUTF8Error,
40013: NtfyBadRequestAttachmentURLInvalidError,
40014: NtfyBadRequestAttachmentsDisallowedError,
40015: NtfyBadRequestAttachmentsExpiryBeforeDeliveryError,
40016: NtfyBadRequestWebSocketsUpgradeHeaderMissingError,
40017: NtfyBadRequestMessageJSONInvalidError,
40018: NtfyBadRequestActionsInvalidError,
40019: NtfyBadRequestMatrixMessageInvalidError,
40021: NtfyBadRequestIconURLInvalidError,
40022: NtfyBadRequestSignupNotEnabledError,
40023: NtfyBadRequestNoTokenProvidedError,
40024: NtfyBadRequestJSONInvalidError,
40025: NtfyBadRequestPermissionInvalidError,
40026: NtfyBadRequestIncorrectwordConfirmationError,
40027: NtfyBadRequestNotAPaidUserError,
40028: NtfyBadRequestBillingRequestInvalidError,
40029: NtfyBadRequestBillingSubscriptionExistsError,
40030: NtfyBadRequestTierInvalidError,
40031: NtfyBadRequestUserNotFoundError,
40032: NtfyBadRequestPhoneCallsDisabledError,
40033: NtfyBadRequestPhoneNumberInvalidError,
40034: NtfyBadRequestPhoneNumberNotVerifiedError,
40035: NtfyBadRequestAnonymousCallsNotAllowedError,
40036: NtfyBadRequestPhoneNumberVerifyChannelInvalidError,
40037: NtfyBadRequestDelayNoCallError,
40038: NtfyBadRequestWebPushSubscriptionInvalidError,
40039: NtfyBadRequestWebPushEndpointUnknownError,
40040: NtfyBadRequestWebPushTopicCountTooHighError,
40041: NtfyBadRequestTemplateMessageTooLargeError,
40042: NtfyBadRequestTemplateMessageNotJSONError,
40043: NtfyBadRequestTemplateInvalidError,
40044: NtfyBadRequestTemplateDisallowedFunctionCallsError,
40045: NtfyBadRequestTemplateExecuteFailedError,
40046: NtfyBadRequestInvalidUsernameError,
40401: NtfyNotFoundPageError,
40101: NtfyUnauthorizedAuthenticationError,
40301: NtfyForbiddenAccessError,
40901: NtfyConflictUserExistsError,
40902: NtfyConflictTopicReservedError,
40903: NtfyConflictSubscriptionExistsError,
40904: NtfyConflictPhoneNumberExistsError,
41001: NtfyGonePhoneVerificationExpiredError,
41301: NtfyRequestEntityTooLargeAttachmentError,
41302: NtfyRequestEntityTooLargeMatrixRequestError,
41303: NtfyRequestEntityTooLargeJSONBodyError,
42901: NtfyTooManyRequestsLimitRequestsError,
42902: NtfyTooManyRequestsLimitEmailsError,
42903: NtfyTooManyRequestsLimitSubscriptionsError,
42904: NtfyTooManyRequestsLimitTotalTopicsError,
42905: NtfyTooManyRequestsLimitAttachmentBandwidthError,
42906: NtfyTooManyRequestsLimitAccountCreationError,
42907: NtfyTooManyRequestsLimitReservationsError,
42908: NtfyTooManyRequestsLimitMessagesError,
42909: NtfyTooManyRequestsLimitAuthFailureError,
42910: NtfyTooManyRequestsLimitCallsError,
50002: NtfyInternalErrorInvalidPathError,
50003: NtfyInternalErrorMissingBaseURLError,
50004: NtfyInternalErrorWebPushUnableToPublishError,
50701: NtfyInsufficientStorageUnifiedPushError,
}
if error_class := error_map.get(code, error_map.get(http)):
raise error_class(code, http, error, link)
raise NtfyUnknownError

View File

@@ -0,0 +1,39 @@
"""Helpers for the aiontfy package."""
import platform
from aiohttp import __version__ as aiohttp_version
from .const import __version__
def get_user_agent() -> str:
"""Generate User-Agent string.
The User-Agent string contains details about the operating system,
its version, architecture, the aiontfy version, aiohttp version,
and Python version.
Returns
-------
str
A User-Agent string with OS details, library versions, and a project URL.
Examples
--------
>>> client.get_user_agent()
'aiontfy/0.0.0 (Windows 11 (10.0.22000); 64bit)
aiohttp/3.10.9 Python/3.12.7 +https://github.com/tr4nt0r/aiontfy')'
"""
os_name = platform.system()
os_version = platform.version()
os_release = platform.release()
arch, _ = platform.architecture()
os_info = f"{os_name} {os_release} ({os_version}); {arch}"
return (
f"aiontfy/{__version__} ({os_info}) "
f"aiohttp/{aiohttp_version} Python/{platform.python_version()} "
" +https://github.com/tr4nt0r/aiontfy)"
)

View File

@@ -0,0 +1,366 @@
"""Async ntfy client library."""
from collections.abc import Callable
from datetime import datetime
from http import HTTPStatus
from typing import Any, Self
from aiohttp import BasicAuth, ClientError, ClientSession, WSMsgType
from yarl import URL
from .exceptions import NtfyConnectionError, NtfyTimeoutError, raise_http_error
from .helpers import get_user_agent
from .types import (
Account,
AccountTokenResponse,
Everyone,
Message,
Notification,
Response,
Stats,
)
class Ntfy:
"""Ntfy client."""
def __init__(
self,
url: str,
session: ClientSession | None = None,
username: str | None = None,
password: str | None = None,
token: str | None = None,
) -> None:
"""Initialize Ntfy client.
Parameters
----------
url : str
The base URL for the Ntfy service.
session : ClientSession, optional
An existing aiohttp ClientSession. If not provided, a new session will be created.
"""
self.url = URL(url)
self._headers = None
if username is not None and password is not None:
self._headers = {"Authorization": BasicAuth(username, password).encode()}
elif token is not None:
self._headers = {"Authorization": f"Bearer {token}"}
if session is not None:
self._session = session
else:
self._session = ClientSession(headers={"User-Agent": get_user_agent()})
self._close_session = True
async def _request(self, method: str, url: URL, **kwargs: Any) -> str: # noqa: ANN401
"""Handle API request.
Parameters
----------
method : str
HTTP method (e.g., 'GET', 'POST').
url : URL
The URL to send the request to.
**kwargs : dict
Additional arguments to pass to the request.
Returns
-------
dict[str, Any]
The JSON response from the API.
Raises
------
NtfyTimeoutError
If a timeout occurs during the request.
NtfyConnectionError
If a client error occurs during the request.
"""
if self._headers:
kwargs.setdefault("headers", {}).update(self._headers)
try:
async with self._session.request(method, url, **kwargs) as r:
if r.status >= HTTPStatus.BAD_REQUEST:
raise_http_error(**(await r.json()))
return await r.text()
except TimeoutError as e:
raise NtfyTimeoutError from e
except ClientError as e:
raise NtfyConnectionError from e
async def publish(self, message: Message) -> Notification:
"""Publish a message to an ntfy topic.
Parameters
----------
message : Message
The message to be published, containing details such as topic, title, and content.
Returns
-------
Notification
A `Notification` object representing the response from the ntfy service.
Raises
------
NtfyTimeoutError
If a timeout occurs during the request.
NtfyConnectionError
If a client error occurs during the request.
"""
return Notification.from_json(
await self._request("POST", self.url, json=message.to_dict())
)
async def subscribe( # noqa: PLR0913
self,
topics: list[str],
callback: Callable[[Notification], None],
title: str | None = None,
message: str | None = None,
tags: list[str] | None = None,
priority: list[int] | None = None,
) -> None:
"""Subscribe to one or more ntfy topics.
Parameters
----------
topics : list[str]
A list of topic names to subscribe to.
callback : Callable[[Notification], None]
A callback function that will be called when a new notification is received.
The callback function should accept a single argument of type `Notification`.
title : str, optional
Filter: Only return messages that match this exact message string, defaults to None.
message : str, optional
Filter: Only return messages that match this exact title string, defaults to None.
tags : list[str], optional
Filter: Only return messages that match all listed tags, defaults to None
priority : int, optional
Filter: Only return messages that match any priority listed, defaults to None.
Raises
------
NtfyTimeoutError
If a timeout occurs during the subscription.
NtfyConnectionError
If a client error occurs during the subscription.
"""
await self.can_subscribe(topics)
url = (
self.url.with_scheme("wss" if self.url.scheme == "https" else "ws")
/ ",".join(topics)
/ "ws"
)
params = {}
if title is not None:
params["title"] = title
if message is not None:
params["message"] = message
if tags is not None:
params["tags"] = ",".join(tags)
if priority is not None:
params["priority"] = ",".join(str(x) for x in priority)
try:
async with self._session.ws_connect(
url, params=params, headers=self._headers
) as ws:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
callback(Notification.from_json(msg.data))
elif msg.type in (
WSMsgType.CLOSE,
WSMsgType.CLOSING,
WSMsgType.CLOSED,
):
break
elif msg.type == WSMsgType.ERROR:
continue
except TimeoutError as e:
raise NtfyTimeoutError from e
except ClientError as e:
raise NtfyConnectionError from e
async def can_subscribe(self, topics: list[str]) -> bool:
"""Check if the client can subscribe to a topic.
Parameters
----------
topics : list of str
A list of topic names to check subscription permissions for.
Returns
-------
bool
True if the client can subscribe to the given topics.
Raises
------
NtfyForbiddenAccessError
If the client is not authorized to subscribe to the given topics.
"""
await self._request("GET", self.url / ",".join(topics) / "auth")
return True
async def stats(self) -> Stats:
"""Get message statistics.
Returns
-------
Stats
An instance of the `Stats` class containing message statistics.
"""
return Stats.from_json(await self._request("GET", self.url / "v1/stats"))
async def account(self) -> Account:
"""Get account information.
Returns
-------
Account
An instance of the `Account` class containing account information.
Raises
------
NtfyUnauthorizedAuthenticationError
If the client is not authorized to access the account information.
"""
return Account.from_json(await self._request("GET", self.url / "v1/account"))
async def generate_token(
self,
label: str | None = None,
expires: datetime | None = None,
) -> AccountTokenResponse:
"""
Generate a token for the account.
Parameters
----------
label : str, optional
A label for the token, defaults to None.
expires : datetime, optional
The expiration date and time for the token. If not provided, the token will not expire.
Returns
-------
AccountTokenResponse
An instance of `AccountTokenResponse` containing the generated token details.
Raises
------
NtfyUnauthorizedAuthenticationError
If the client is not authenticated.
"""
payload = {
"label": label,
"expires": int(expires.timestamp()) if expires else 0,
}
return AccountTokenResponse.from_json(
await self._request("POST", self.url / "v1/account/token", json=payload)
)
async def reservation(self, topic: str, everyone: Everyone) -> bool:
"""Reserve or change the reservation status of a topic.
Parameters
----------
topic : str
The topic to reserve.
everyone : str
The reservation status to set for the topic.
Returns
-------
bool
True if successfull.
"""
return Response.from_json(
await self._request(
"POST",
self.url / "v1/account/reservation",
json={"topic": topic, "everyone": everyone.value},
)
).success
async def delete_reservation(
self, topic: str, *, delete_messages: bool = False
) -> bool:
"""Delete a topic reservation.
Parameters
----------
topic : str
The name of the topic whose reservation is to be deleted.
delete_messages : bool, optional
If True, deletes all messages and attachments that are cached on the server
otherwise they will become publicly available. Defaults to False.
Returns
-------
bool
True if the reservation was successfully deleted, False otherwise.
Raises
------
NtfyUnauthorizedAuthenticationError
If the client is not authenticated or the reservation does not exist.
"""
kwargs = {}
if delete_messages:
kwargs["headers"] = {"X-Delete-Messages": "true"}
return Response.from_json(
await self._request(
"DELETE", self.url / "v1/account/reservation" / topic, **kwargs
)
).success
async def close(self) -> None:
"""Close session.
Closes the aiohttp ClientSession if it is not already closed.
"""
if not self._session.closed:
await self._session.close()
async def __aenter__(self) -> Self:
"""Async enter.
Returns
-------
Self
The Ntfy client instance.
"""
return self
async def __aexit__(self, *exc_info: object) -> None:
"""Async exit.
Closes the aiohttp ClientSession if it was created by this instance.
Parameters
----------
*exc_info : object
Exception information.
"""
if self._close_session:
await self.close()

View File

@@ -0,0 +1,391 @@
"""Type definitions for aiontfy."""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import IntEnum, StrEnum
from mashumaro import field_options
from mashumaro.mixins.orjson import DataClassORJSONMixin
from yarl import URL
from .const import MAX_PRIORITY, MIN_PRIORITY
class DeleteAfter(IntEnum):
"""Delete after periods."""
NEVER = 0
AFTER_THREE_HRS = 10800
AFTER_ONE_DAY = 86400
AFTER_ONE_WEEK = 604800
AFTER_ONE_MONTH = 2592000
class Priority(IntEnum):
"""Message priority."""
MIN = 1
LOW = 2
DEFAULT = 3
HIGH = 4
MAX = 5
class Sound(StrEnum):
"""Notification sound."""
NO_SOUND = "none"
DING = "ding"
JUNTOS = "juntos"
PRISTINE = "pristine"
DADUM = "dadum"
POP = "pop"
POP_SWOOSH = "pop-swoosh"
BEEP = "beep"
class Everyone(StrEnum):
"""Everyone access."""
DENY = "deny-all"
READ = "read-only"
WRITE = "write-only"
READ_WRITE = "read-write"
@dataclass(kw_only=True, frozen=True)
class HttpAction(DataClassORJSONMixin):
"""An Http ntfy action.
Attributes
----------
label : str
Label of the action button in the notification.
url : URL
URL to which the HTTP request will be sent.
method : str, optional
HTTP method to use for request, default is POST.
headers : dict[str, str] or None, optional
HTTP headers to pass in request.
body : str or None, optional
HTTP body.
clear : bool, optional
Clear notification after HTTP request succeeds. If the request fails, the notification is not cleared.
"""
action: str = field(default="http", init=False)
label: str
url: URL = field(metadata=field_options(serialize=str, deserialize=URL))
method: str = "POST"
headers: dict[str, str] | None = None
body: str | None = None
clear: bool = False
@dataclass(kw_only=True, frozen=True)
class BroadcastAction(DataClassORJSONMixin):
"""A broadcast ntfy action.
Attributes
----------
label : str
Label of the action button in the notification.
intent : str or None, optional
Android intent name, default is io.heckel.ntfy.USER_ACTION.
extras : dict[str, str] or None, optional
Android intent extras. Currently, only string extras are supported.
clear : bool, optional
Clear notification after action button is tapped.
"""
action: str = field(default="broadcast", init=False)
label: str
intent: str | None = None
extras: dict[str, str] | None = None
clear: bool = False
@dataclass(kw_only=True, frozen=True)
class ViewAction(DataClassORJSONMixin):
"""A view ntfy action.
Attributes
----------
label : str
Label of the action button in the notification.
url : URL
URL to open when action is tapped.
clear : bool, optional
Clear notification after action button is tapped.
"""
action: str = field(default="view", init=False)
label: str
url: URL = field(metadata=field_options(serialize=str, deserialize=URL))
clear: bool = False
@dataclass(kw_only=True, frozen=True)
class Message(DataClassORJSONMixin):
"""A message to publish to ntfy.
Attributes
----------
topic : str
Target topic name.
message : str or None, optional
Message body; set to triggered if empty or not passed.
title : str or None, optional
Message title. Defaults to the topic short URL (ntfy.sh/mytopic) if not set.
tags : list[str], optional
List of tags that may or not map to emojis (https://docs.ntfy.sh/emojis/).
priority : int or None, optional
Message priority with 1=min, 3=default and 5=max
actions : list[ViewAction or BroadcastAction or HttpAction], optional
Custom user action buttons for notifications.
click : URL or None, optional
Website opened when notification is clicked.
attach : URL or None, optional
URL of an attachment.
markdown : bool, optional
Set to true if the message is Markdown-formatted.
icon : URL or None, optional
URL to use as notification icon.
filename : str or None, optional
File name of the attachment.
delay : str or None, optional
Timestamp or duration for delayed delivery.
email : str or None, optional
E-mail address for e-mail notifications.
call : str or None, optional
Phone number to use for voice call.
"""
topic: str
message: str | None = None
title: str | None = None
tags: list[str] = field(default_factory=list)
priority: int | None = None
actions: list[ViewAction | BroadcastAction | HttpAction] = field(
default_factory=list
)
click: URL | None = field(
default=None, metadata=field_options(serialize=str, deserialize=URL)
)
attach: URL | None = field(
default=None, metadata=field_options(serialize=str, deserialize=URL)
)
markdown: bool = False
icon: URL | None = field(
default=None, metadata=field_options(serialize=str, deserialize=URL)
)
filename: str | None = None
delay: str | None = None
email: str | None = None
call: str | None = None
def __post_init__(self) -> None:
"""Post-initialization processing to validate attributes.
Raises
------
ValueError
If the priority is not between the minimum and maximum allowed values.
"""
if self.priority is not None and (
self.priority < MIN_PRIORITY or self.priority > MAX_PRIORITY
):
msg = f"Priority must be between {MIN_PRIORITY} and {MAX_PRIORITY}"
raise ValueError(msg)
class Event(StrEnum):
"""Message type."""
OPEN = "open"
KEEPALIVE = "keepalive"
MESSAGE = "message"
POLL_REQUEST = "poll_request"
def timestamp(ts: int) -> datetime:
"""Serialize timestamp to datetime."""
return datetime.fromtimestamp(ts, tz=UTC)
@dataclass(kw_only=True, frozen=True)
class Attachment(DataClassORJSONMixin):
"""Details about an attachment."""
name: str
url: URL = field(metadata=field_options(serialize=str, deserialize=URL))
type: str | None = None
size: int | None = None
expires: datetime | None = field(
default=None, metadata=field_options(deserialize=timestamp)
)
@dataclass(kw_only=True, frozen=True)
class Notification(DataClassORJSONMixin):
"""A notification received from a subscribed topic."""
id: str
time: datetime = field(metadata=field_options(deserialize=timestamp))
expires: datetime | None = field(
default=None, metadata=field_options(deserialize=timestamp)
)
event: Event
topic: str
message: str | None = None
title: str | None = None
tags: list[str] = field(default_factory=list)
priority: Priority | None = None
click: URL | None = field(
default=None, metadata=field_options(serialize=str, deserialize=URL)
)
icon: URL | None = field(
default=None, metadata=field_options(serialize=str, deserialize=URL)
)
actions: list[ViewAction | BroadcastAction | HttpAction] = field(
default_factory=list
)
attachment: Attachment | None = None
content_type: str | None = None
@dataclass(kw_only=True, frozen=True)
class Stats(DataClassORJSONMixin):
"""Stats response.
Attributes
----------
messages : int
The total number of messages.
messages_rate : float
Average number of messages per second.
"""
messages: int
messages_rate: float
@dataclass(kw_only=True, frozen=True)
class Subscription(DataClassORJSONMixin):
"""Subscription information."""
base_url: URL = field(metadata=field_options(serialize=str, deserialize=URL))
topic: str
display_name: str
@dataclass(kw_only=True, frozen=True)
class NotificationPrefs(DataClassORJSONMixin):
"""Notification preferences."""
sound: Sound | None = None
min_priority: Priority | None = None
delete_after: DeleteAfter | None = None
@dataclass(kw_only=True, frozen=True)
class AccountTokenResponse(DataClassORJSONMixin):
"""Account token response."""
token: str
label: str | None = None
last_access: datetime = field(metadata=field_options(deserialize=timestamp))
last_origin: str | None = None
expires: datetime | None = field(
default=None, metadata=field_options(deserialize=timestamp)
)
@dataclass(kw_only=True, frozen=True)
class AccountTier(DataClassORJSONMixin):
"""Account tear information."""
code: str
name: str
@dataclass(kw_only=True, frozen=True)
class AccountLimits(DataClassORJSONMixin):
"""Account limits information."""
basis: str | None = None
messages: int
messages_expiry_duration: int
emails: int
calls: int
reservations: int
attachment_total_size: int
attachment_file_size: int
attachment_expiry_duration: int
attachment_bandwidth: int
@dataclass(kw_only=True, frozen=True)
class AccountStats(DataClassORJSONMixin):
"""Account stats."""
messages: int
messages_remaining: int
emails: int
emails_remaining: int
calls: int
calls_remaining: int
reservations: int
reservations_remaining: int
attachment_total_size: int
attachment_total_size_remaining: int
@dataclass(kw_only=True, frozen=True)
class Reservation(DataClassORJSONMixin):
"""Topic reservation settings."""
topic: str
everyone: str
@dataclass(kw_only=True, frozen=True)
class AccountBilling(DataClassORJSONMixin):
"""Acount billing information."""
customer: bool
subscription: bool
status: str | None = None
interval: str | None = None
paid_until: datetime = field(metadata=field_options(deserialize=timestamp))
cancel_at: datetime | None = field(
default=None, metadata=field_options(deserialize=timestamp)
)
@dataclass(kw_only=True, frozen=True)
class Account(DataClassORJSONMixin):
"""Account response."""
username: str
role: str | None = None
sync_topic: str | None = None
language: str | None = None
notification: NotificationPrefs | None = None
subscriptions: list[Subscription] = field(default_factory=list)
reservations: list[Reservation] = field(default_factory=list)
tokens: list[AccountTokenResponse] = field(default_factory=list)
tier: AccountTier | None = None
limits: AccountLimits | None = None
stats: AccountStats
billing: AccountBilling | None = None
@dataclass(kw_only=True, frozen=True)
class Response(DataClassORJSONMixin):
"""Success response."""
success: bool