platypush/platypush/plugins/ntfy/__init__.py
Fabio Manganiello c3337ccc6c
[#311] Docs deps autogen sphinx plugin.
Added an `add_dependencies` plugin to the Sphinx build process that
parses the manifest files of the scanned backends and plugins and
automatically generates the documentation for the required dependencies
and triggered events.

This means that those dependencies are no longer required to be listed
in the docstring of the class itself.

Also in this commit:

- Black/LINT for some integrations that hadn't been touched in a long
  time.

- Deleted some leftovers from previous refactors (deprecated
  `backend.mqtt`, `backend.zwave.mqtt`, `backend.http.request.rss`).

- Deleted deprecated `inotify` backend - replaced by `file.monitor` (see
  #289).
2023-09-24 17:00:08 +02:00

252 lines
9.6 KiB
Python

import asyncio
import json
import os
from typing import Any, Callable, Dict, Optional, Collection, Mapping
import requests
import websockets
import websockets.exceptions
from platypush.context import get_bus
from platypush.message.event.ntfy import NotificationEvent
from platypush.plugins import AsyncRunnablePlugin, action
class NtfyPlugin(AsyncRunnablePlugin):
"""
Ntfy integration.
`ntfy <https://ntfy.sh/>`_ allows you to process asynchronous notification
across multiple devices and it's compatible with the
`UnifiedPush <https://unifiedpush.org/>` specification.
"""
def __init__(
self,
server_url: str = 'https://ntfy.sh',
subscriptions: Optional[Collection[str]] = None,
**kwargs,
):
"""
:param server_url: Default ntfy instance base URL (default: ``https://ntfy.sh``).
:param subscriptions: List of topics the plugin should subscribe to
(default: none).
"""
super().__init__(**kwargs)
self._server_url = server_url
self._ws_url = '/'.join(
[
self._server_url.split('/')[0].replace('http', 'ws'),
*self._server_url.split('/')[1:],
]
)
self._subscriptions = subscriptions or []
async def _get_ws_handler(self, url):
reconnect_wait_secs = 1
reconnect_wait_secs_max = 60
while not self.should_stop():
self.logger.debug('Connecting to %s', url)
try:
async with websockets.connect(url) as ws: # pylint: disable=no-member
reconnect_wait_secs = 1
self.logger.info('Connected to %s', url)
async for msg in ws:
try:
msg = json.loads(msg)
except json.JSONDecodeError as e:
self.logger.warning(
'Received invalid JSON message from the server: %s\n%s',
e,
msg,
)
continue
self.logger.debug('Received message on ntfy: %s', msg)
if msg.get('event') == 'message':
get_bus().post(
NotificationEvent(
id=msg['id'],
time=msg['time'],
topic=msg['topic'],
message=msg.get('message'),
title=msg.get('title'),
tags=msg.get('tags'),
url=msg.get('click'),
actions=msg.get('actions'),
attachment=msg.get('attachment'),
)
)
except websockets.exceptions.WebSocketException as e:
self.logger.error('Websocket error: %s', e)
await asyncio.sleep(reconnect_wait_secs)
reconnect_wait_secs = min(
reconnect_wait_secs * 2, reconnect_wait_secs_max
)
async def listen(self):
return await asyncio.gather(
*[
self._get_ws_handler(f'{self._ws_url}/{sub}/ws')
for sub in set(self._subscriptions)
]
)
@property
def _should_start_runner(self):
return bool(self._subscriptions)
@action
def send_message(
self,
topic: str,
message: str = '',
server_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
title: Optional[str] = None,
url: Optional[str] = None,
attachment: Optional[str] = None,
filename: Optional[str] = None,
actions: Optional[Collection[Mapping[str, str]]] = None,
email: Optional[str] = None,
priority: Optional[str] = None,
tags: Optional[Collection[str]] = None,
schedule: Optional[str] = None,
):
r"""
Send a message/notification to a topic.
:param topic: Topic where the message will be delivered.
:param message: Text of the message to be sent.
:param server_url: Override the default server URL.
:param username: Set if publishing to the topic requires authentication
:param password: Set if publishing to the topic requires authentication
:param title: Custom notification title.
:param url: URL that should be opened when the user clicks the
notification. It can be an ``http(s)://`` URL, a ``mailto:`, a
``geo:``, a link to another ntfy topic (e.g. ``ntfy://mytopic``) or
a Twitter link (e.g. ``twitter://user?screen_name=myname``).
:param attachment: Attach a file or URL to the notification. It can
either be an HTTP URL or a path to a local file.
:param filename: If ``attachment`` is specified, you can override the
output filename (default: same filename as the URL/path base name).
:param actions: List of objects describing possible action buttons
available for the notification. Supported types:
- ``view``: Open a URL or an app when the action button is
clicked
- ``http``: Send an HTTP request upon action selection.
- ``broadcast``: Send an `Android broadcast <https://developer.android.com/guide/components/broadcasts>`
intent upon action selection (only available on Android).
Actions example:
.. code-block:: json
[
{
"action": "view",
"label": "Open portal",
"url": "https://home.nest.com/",
"clear": true
},
{
"action": "http",
"label": "Turn down",
"url": "https://api.nest.com/",
"method": "PUT",
"headers": {
"Authorization": "Bearer abcdef..."
},
"body": "{\"temperature\": 65}"
},
{
"action": "broadcast",
"label": "Take picture",
"intent": "com.myapp.TAKE_PICTURE_INTENT",
"extras": {
"camera": "front"
}
}
]
:param email: Forward the notification as an email to the specified
address.
:param priority: Custom notification priority. Supported values:
``[max, high, default, low, min]``.
:param tags: Optional list of tags associated with the notification.
Tag names that match emoji short codes will be rendered as emojis
in the notification - see `here <https://ntfy.sh/docs/emojis/>` for
a list of supported emojis.
:param schedule: Schedule the message to be delivered at a specific
time (for example, for reminders). Supported formats:
- UNIX timestamps
- Duration (e.g. ``30m``, ``3h``, ``2 days``)
- Natural language strings (e.g. ``Tuesday, 7am`` or
``tomorrow, 3pm``)
"""
method: Callable[..., requests.Response] = requests.post
url = server_url or self._server_url
args: Dict[str, Any] = {}
if username and password:
args['auth'] = (username, password)
if attachment and not (
attachment.startswith('http://') or attachment.startswith('https://')
):
url = f'{url}/{topic}'
attachment = os.path.expanduser(attachment)
filename = filename or os.path.basename(attachment)
args['headers'] = {
'Filename': filename,
**({'X-Title': title} if title else {}),
**({'X-Click': url} if url else {}),
**({'X-Email': email} if email else {}),
**({'X-Priority': priority} if priority else {}),
**({'X-Tags': ','.join(tags)} if tags else {}),
**({'X-Delay': schedule} if schedule else {}),
}
with open(attachment, 'rb') as f:
args['data'] = f.read()
method = requests.put
else:
args['json'] = {
'topic': topic,
'message': message,
**({'title': title} if title else {}),
**({'click': url} if url else {}),
**({'email': email} if email else {}),
**({'priority': priority} if priority else {}),
**({'tags': tags} if tags else {}),
**({'delay': schedule} if schedule else {}),
**({'actions': actions} if actions else {}),
**(
{
'attach': attachment,
'filename': (
filename
if filename
else attachment.split('/')[-1].split('?')[0]
),
}
if attachment
else {}
),
}
rs = method(url, **args)
assert rs.ok, f'Could not send message to {topic}: ' + rs.json().get(
'error', f'HTTP error: {rs.status_code}'
)
return rs.json()
# vim:sw=4:ts=4:et: