forked from platypush/platypush
Merge branch '198-feature-request-gotify-push-intergration' into 'master'
Resolve "[Feature Request] Gotify Push Intergration" Closes #198 See merge request platypush/platypush!6
This commit is contained in:
commit
ee35ea995b
6 changed files with 345 additions and 5 deletions
|
@ -3,6 +3,12 @@
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2.
|
Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2.
|
||||||
|
|
||||||
|
## [0.22.3] - 2021-10-01
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- `gotify` integration (see #198).
|
||||||
|
|
||||||
## [0.22.2] - 2021-09-25
|
## [0.22.2] - 2021-09-25
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
44
platypush/message/event/gotify.py
Normal file
44
platypush/message/event/gotify.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.message.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class GotifyEvent(Event):
|
||||||
|
"""
|
||||||
|
Gotify base event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class GotifyMessageEvent(GotifyEvent):
|
||||||
|
"""
|
||||||
|
Event triggered when a message is received on the Gotify instance.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args,
|
||||||
|
message: str,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
priority: Optional[int] = None,
|
||||||
|
extras: Optional[dict] = None,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
id: Optional[int] = None,
|
||||||
|
appid: Optional[int] = None,
|
||||||
|
**kwargs):
|
||||||
|
"""
|
||||||
|
:param message: Message body.
|
||||||
|
:param title: Message title.
|
||||||
|
:param priority: Message priority.
|
||||||
|
:param extras: Message extra payload.
|
||||||
|
:param date: Delivery datetime.
|
||||||
|
:param id: Message ID.
|
||||||
|
:param appid: ID of the sender application.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
*args,
|
||||||
|
message=message,
|
||||||
|
title=title,
|
||||||
|
priority=priority,
|
||||||
|
extras=extras,
|
||||||
|
date=date,
|
||||||
|
id=id,
|
||||||
|
appid=appid,
|
||||||
|
**kwargs
|
||||||
|
)
|
200
platypush/plugins/gotify/__init__.py
Normal file
200
platypush/plugins/gotify/__init__.py
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
from platypush.context import get_bus
|
||||||
|
from platypush.message.event.gotify import GotifyMessageEvent
|
||||||
|
from platypush.plugins import RunnablePlugin, action
|
||||||
|
from platypush.schemas.gotify import GotifyMessageSchema
|
||||||
|
|
||||||
|
|
||||||
|
class GotifyPlugin(RunnablePlugin):
|
||||||
|
"""
|
||||||
|
Gotify integration.
|
||||||
|
|
||||||
|
`Gotify <https://gotify.net>`_ allows you process messages and notifications asynchronously
|
||||||
|
over your own devices without relying on 3rd-party cloud services.
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
|
||||||
|
* :class:`platypush.message.event.gotify.GotifyMessageEvent` when a new message is received.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, server_url: str, app_token: str, client_token: str, **kwargs):
|
||||||
|
"""
|
||||||
|
:param server_url: Base URL of the Gotify server (e.g. ``http://localhost``).
|
||||||
|
:param app_token: Application token, required to send message and retrieve application info.
|
||||||
|
You can create a new application under ``http://<server_url>/#/applications``.
|
||||||
|
:param client_token: Client token, required to subscribe to messages.
|
||||||
|
You can create a new client under ``http://<server_url>/#/clients``.
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.server_url = server_url
|
||||||
|
self.app_token = app_token
|
||||||
|
self.client_token = client_token
|
||||||
|
self._ws_app: Optional[websocket.WebSocketApp] = None
|
||||||
|
self._state_lock = multiprocessing.RLock()
|
||||||
|
self._connected_event = multiprocessing.Event()
|
||||||
|
self._disconnected_event = multiprocessing.Event()
|
||||||
|
self._ws_listener: Optional[multiprocessing.Process] = None
|
||||||
|
|
||||||
|
def _execute(self, method: str, endpoint: str, **kwargs) -> dict:
|
||||||
|
method = method.lower()
|
||||||
|
rs = getattr(requests, method)(
|
||||||
|
f'{self.server_url}/{endpoint}',
|
||||||
|
headers={
|
||||||
|
'X-Gotify-Key': self.app_token if method == 'post' else self.client_token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
**kwargs.pop('headers', {}),
|
||||||
|
},
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
rs.raise_for_status()
|
||||||
|
try:
|
||||||
|
return rs.json()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(e)
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
self._connect()
|
||||||
|
stop_events = []
|
||||||
|
|
||||||
|
while not any(stop_events):
|
||||||
|
stop_events = self._should_stop.wait(timeout=1), self._disconnected_event.wait(timeout=1)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._ws_app:
|
||||||
|
self._ws_app.close()
|
||||||
|
self._ws_app = None
|
||||||
|
|
||||||
|
if self._ws_listener and self._ws_listener.is_alive():
|
||||||
|
self.logger.info('Terminating websocket process')
|
||||||
|
self._ws_listener.terminate()
|
||||||
|
self._ws_listener.join(5)
|
||||||
|
|
||||||
|
if self._ws_listener and self._ws_listener.is_alive():
|
||||||
|
self.logger.warning('Terminating the websocket process failed, killing the process')
|
||||||
|
self._ws_listener.kill()
|
||||||
|
|
||||||
|
if self._ws_listener:
|
||||||
|
self._ws_listener.join()
|
||||||
|
self._ws_listener = None
|
||||||
|
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
with self._state_lock:
|
||||||
|
if self.should_stop() or self._connected_event.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
ws_url = '/'.join([self.server_url.split('/')[0].replace('http', 'ws'), *self.server_url.split('/')[1:]])
|
||||||
|
self._ws_app = websocket.WebSocketApp(
|
||||||
|
f'{ws_url}/stream?token={self.client_token}',
|
||||||
|
on_open=self._on_open(),
|
||||||
|
on_message=self._on_msg(),
|
||||||
|
on_error=self._on_error(),
|
||||||
|
on_close=self._on_close()
|
||||||
|
)
|
||||||
|
|
||||||
|
def server():
|
||||||
|
self._ws_app.run_forever()
|
||||||
|
|
||||||
|
self._ws_listener = multiprocessing.Process(target=server)
|
||||||
|
self._ws_listener.start()
|
||||||
|
|
||||||
|
def _on_open(self):
|
||||||
|
def hndl(*_):
|
||||||
|
with self._state_lock:
|
||||||
|
self._disconnected_event.clear()
|
||||||
|
self._connected_event.set()
|
||||||
|
self.logger.info('Connected to the Gotify websocket')
|
||||||
|
|
||||||
|
return hndl
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _on_msg():
|
||||||
|
def hndl(*args):
|
||||||
|
data = json.loads(args[1] if len(args) > 1 else args[0])
|
||||||
|
get_bus().post(GotifyMessageEvent(**GotifyMessageSchema().dump(data)))
|
||||||
|
|
||||||
|
return hndl
|
||||||
|
|
||||||
|
def _on_error(self):
|
||||||
|
def hndl(*args):
|
||||||
|
error = args[1] if len(args) > 1 else args[0]
|
||||||
|
ws = args[0] if len(args) > 1 else None
|
||||||
|
self.logger.warning('Gotify websocket error: {}'.format(error))
|
||||||
|
if ws:
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
return hndl
|
||||||
|
|
||||||
|
def _on_close(self):
|
||||||
|
def hndl(*_):
|
||||||
|
with self._state_lock:
|
||||||
|
self._disconnected_event.set()
|
||||||
|
self._connected_event.clear()
|
||||||
|
self.logger.warning('Gotify websocket connection closed')
|
||||||
|
|
||||||
|
return hndl
|
||||||
|
|
||||||
|
@action
|
||||||
|
def send_message(self, message: str, title: Optional[str] = None, priority: int = 0, extras: Optional[dict] = None):
|
||||||
|
"""
|
||||||
|
Send a message to the server.
|
||||||
|
|
||||||
|
:param message: Message body (Markdown is supported).
|
||||||
|
:param title: Message title.
|
||||||
|
:param priority: Message priority (default: 0).
|
||||||
|
:param extras: Extra JSON payload to be passed on the message.
|
||||||
|
:return: .. schema:: gotify.GotifyMessageSchema
|
||||||
|
"""
|
||||||
|
return GotifyMessageSchema().dump(
|
||||||
|
self._execute('post', 'message', json={
|
||||||
|
'message': message,
|
||||||
|
'title': title,
|
||||||
|
'priority': priority,
|
||||||
|
'extras': extras or {},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def get_messages(self, limit: int = 100, since: Optional[int] = None):
|
||||||
|
"""
|
||||||
|
Get a list of the messages received on the server.
|
||||||
|
|
||||||
|
:param limit: Maximum number of messages to retrieve (default: 100).
|
||||||
|
:param since: Retrieve the message having ``since`` as minimum ID.
|
||||||
|
:return: .. schema:: gotify.GotifyMessageSchema(many=True)
|
||||||
|
"""
|
||||||
|
return GotifyMessageSchema().dump(
|
||||||
|
self._execute(
|
||||||
|
'get', 'message', params={
|
||||||
|
'limit': limit,
|
||||||
|
**({'since': since} if since else {}),
|
||||||
|
}
|
||||||
|
).get('messages', []), many=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def delete_messages(self, *ids):
|
||||||
|
"""
|
||||||
|
Delete messages.
|
||||||
|
|
||||||
|
:param ids: If specified, it deletes the messages matching these IDs.
|
||||||
|
Otherwise, it deletes all the received messages.
|
||||||
|
"""
|
||||||
|
if not ids:
|
||||||
|
self._execute('delete', 'message')
|
||||||
|
return
|
||||||
|
|
||||||
|
for id in ids:
|
||||||
|
self._execute('delete', f'message/{id}')
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
4
platypush/plugins/gotify/manifest.yaml
Normal file
4
platypush/plugins/gotify/manifest.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
manifest:
|
||||||
|
events: {}
|
||||||
|
package: platypush.plugins.gotify
|
||||||
|
type: plugin
|
|
@ -1,6 +1,8 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from dateutil.parser import isoparse
|
||||||
|
from dateutil.tz import tzutc
|
||||||
from marshmallow import fields
|
from marshmallow import fields
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,9 +17,26 @@ class StrippedString(fields.Function): # lgtm [py/missing-call-to-init]
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
def normalize_datetime(dt: str) -> Optional[datetime]:
|
class DateTime(fields.Function):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.metadata = {
|
||||||
|
'example': datetime.now(tz=tzutc()).isoformat(),
|
||||||
|
**(self.metadata or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]:
|
||||||
|
value = normalize_datetime(obj.get(attr))
|
||||||
|
if value:
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data, **kwargs) -> Optional[datetime]:
|
||||||
|
return normalize_datetime(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_datetime(dt: Union[str, datetime]) -> Optional[datetime]:
|
||||||
if not dt:
|
if not dt:
|
||||||
return
|
return
|
||||||
if dt.endswith('Z'):
|
if isinstance(dt, datetime):
|
||||||
dt = dt[:-1] + '+00:00'
|
return dt
|
||||||
return datetime.fromisoformat(dt)
|
return isoparse(dt)
|
||||||
|
|
67
platypush/schemas/gotify.py
Normal file
67
platypush/schemas/gotify.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from marshmallow import fields
|
||||||
|
from marshmallow.schema import Schema
|
||||||
|
|
||||||
|
from . import DateTime
|
||||||
|
|
||||||
|
|
||||||
|
class GotifyMessageSchema(Schema):
|
||||||
|
title = fields.String(
|
||||||
|
metadata=dict(
|
||||||
|
description='Message title',
|
||||||
|
example='Test title',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
message = fields.String(
|
||||||
|
required=True,
|
||||||
|
metadata=dict(
|
||||||
|
description='Message body (markdown is supported)',
|
||||||
|
example='Test message',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
priority = fields.Int(
|
||||||
|
missing=0,
|
||||||
|
metadata=dict(
|
||||||
|
description='Message priority',
|
||||||
|
example=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
extras = fields.Dict(
|
||||||
|
metadata=dict(
|
||||||
|
description='Extra payload to be delivered with the message',
|
||||||
|
example={
|
||||||
|
'home::appliances::lighting::on': {
|
||||||
|
'brightness': 15
|
||||||
|
},
|
||||||
|
'home::appliances::thermostat::change_temperature': {
|
||||||
|
'temperature': 23
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
id = fields.Int(
|
||||||
|
required=True,
|
||||||
|
dump_only=True,
|
||||||
|
metadata=dict(
|
||||||
|
description='Message ID',
|
||||||
|
example=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
appid = fields.Int(
|
||||||
|
dump_only=True,
|
||||||
|
metadata=dict(
|
||||||
|
description='ID of the app that posted the message',
|
||||||
|
example=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
date = DateTime(
|
||||||
|
dump_only=True,
|
||||||
|
metadata=dict(
|
||||||
|
description='Message date',
|
||||||
|
)
|
||||||
|
)
|
Loading…
Reference in a new issue