From 9fa5989e2195c4fe1bc4ef96439409643d8e0dde Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 10 Jan 2024 00:41:51 +0100 Subject: [PATCH] [#302] Merged `pushbullet` backend and plugin. Also, added support for more granular Pushbullet events. Closes: #302 --- README.md | 4 +- docs/source/backends.rst | 1 - docs/source/platypush/backend/pushbullet.rst | 6 - .../http/webapp/src/components/Pushbullet.vue | 25 +- platypush/backend/pushbullet/__init__.py | 204 -------- platypush/backend/pushbullet/manifest.yaml | 16 - platypush/context/__init__.py | 2 +- platypush/message/event/pushbullet.py | 190 ++++++- platypush/plugins/pushbullet/__init__.py | 469 ++++++++++++++---- .../pushbullet/listener.py | 15 +- platypush/plugins/pushbullet/manifest.yaml | 21 +- platypush/schemas/pushbullet.py | 324 ++++++++++++ setup.py | 2 +- 13 files changed, 905 insertions(+), 374 deletions(-) delete mode 100644 docs/source/platypush/backend/pushbullet.rst delete mode 100644 platypush/backend/pushbullet/__init__.py delete mode 100644 platypush/backend/pushbullet/manifest.yaml rename platypush/{backend => plugins}/pushbullet/listener.py (76%) create mode 100644 platypush/schemas/pushbullet.py diff --git a/README.md b/README.md index ca567b31a..6ab0333d1 100644 --- a/README.md +++ b/README.md @@ -419,9 +419,7 @@ backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an [MQTT instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a [Kafka -instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html), -[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html) -etc.). +instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html). If a backend supports the execution of requests (e.g. HTTP, MQTT, Kafka, Websocket and TCP) then you can send requests to these services in JSON format. diff --git a/docs/source/backends.rst b/docs/source/backends.rst index 4b9c06aab..26dcef185 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -23,7 +23,6 @@ Backends platypush/backend/nextcloud.rst platypush/backend/nfc.rst platypush/backend/nodered.rst - platypush/backend/pushbullet.rst platypush/backend/redis.rst platypush/backend/scard.rst platypush/backend/sensor.ir.zeroborg.rst diff --git a/docs/source/platypush/backend/pushbullet.rst b/docs/source/platypush/backend/pushbullet.rst deleted file mode 100644 index 7d8f251ea..000000000 --- a/docs/source/platypush/backend/pushbullet.rst +++ /dev/null @@ -1,6 +0,0 @@ -``pushbullet`` -================================ - -.. automodule:: platypush.backend.pushbullet - :members: - diff --git a/platypush/backend/http/webapp/src/components/Pushbullet.vue b/platypush/backend/http/webapp/src/components/Pushbullet.vue index a3a4894f3..a84391026 100644 --- a/platypush/backend/http/webapp/src/components/Pushbullet.vue +++ b/platypush/backend/http/webapp/src/components/Pushbullet.vue @@ -6,26 +6,27 @@ import Utils from "@/Utils"; export default { - name: "Pushbullet", mixins: [Utils], methods: { onMessage(event) { - if (event.push_type === 'mirror') { - this.notify({ - title: event.title, - text: event.body, - image: { - src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined, - icon: event.icon ? undefined : 'bell', - }, - }); - } + this.notify({ + title: event.title, + text: event.body, + image: { + src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined, + icon: event.icon ? undefined : 'bell', + }, + }) }, }, mounted() { - this.subscribe(this.onMessage, null, 'platypush.message.event.pushbullet.PushbulletEvent') + this.subscribe( + this.onMessage, + null, + 'platypush.message.event.pushbullet.PushbulletNotificationEvent' + ) }, } diff --git a/platypush/backend/pushbullet/__init__.py b/platypush/backend/pushbullet/__init__.py deleted file mode 100644 index c328697b1..000000000 --- a/platypush/backend/pushbullet/__init__.py +++ /dev/null @@ -1,204 +0,0 @@ -import json -import time -from typing import Optional - -from platypush.backend import Backend -from platypush.message.event.pushbullet import PushbulletEvent - - -class PushbulletBackend(Backend): - """ - This backend will listen for events on a Pushbullet (https://pushbullet.com) - channel and propagate them to the bus. This backend is quite useful if you - want to synchronize events and actions with your mobile phone (through the - Pushbullet app and/or through Tasker), synchronize clipboards, send pictures - and files to other devices etc. You can also wrap Platypush messages as JSON - into a push body to execute them. - """ - - def __init__( - self, - token: str, - device: str = 'Platypush', - proxy_host: Optional[str] = None, - proxy_port: Optional[int] = None, - **kwargs, - ): - """ - :param token: Your Pushbullet API token, see https://docs.pushbullet.com/#authentication - :param device: Name of the virtual device for Platypush (default: Platypush) - :param proxy_host: HTTP proxy host (default: None) - :param proxy_port: HTTP proxy port (default: None) - """ - super().__init__(**kwargs) - - self.token = token - self.device_name = device - self.proxy_host = proxy_host - self.proxy_port = proxy_port - self.device = None - self.pb_device_id = None - self.pb = None - self.listener = None - - def _initialize(self): - # noinspection PyPackageRequirements - from pushbullet import Pushbullet - - self.pb = Pushbullet(self.token) - - try: - self.device = self.pb.get_device(self.device_name) - except Exception as e: - self.logger.info( - f'Device {self.device_name} does not exist: {e}. Creating it' - ) - self.device = self.pb.new_device(self.device_name) - - self.pb_device_id = self.get_device_id() - - def _get_latest_push(self): - t = int(time.time()) - 5 - pushes = self.pb.get_pushes(modified_after=str(t), limit=1) - if pushes: - return pushes[0] - - def on_push(self): - def callback(data): - try: - # Parse the push - try: - data = json.loads(data) if isinstance(data, str) else data - except Exception as e: - self.logger.exception(e) - return - - # If it's a push, get it - if data['type'] == 'tickle' and data['subtype'] == 'push': - push = self._get_latest_push() - elif data['type'] == 'push': - push = data['push'] - else: - return # Not a push notification - - if not push: - return - - # Post an event, useful to react on mobile notifications if - # you enabled notification mirroring on your PushBullet app - event = PushbulletEvent(**push) - self.on_message(event) - - if 'body' not in push: - return - self.logger.debug(f'Received push: {push}') - - body = push['body'] - try: - body = json.loads(body) - self.on_message(body) - except Exception as e: - self.logger.debug( - 'Unexpected message received on the ' - + f'Pushbullet backend: {e}. Message: {body}' - ) - except Exception as e: - self.logger.exception(e) - return - - return callback - - def get_device_id(self): - # noinspection PyBroadException - try: - return self.pb.get_device(self.device_name).device_iden - except Exception: - device = self.pb.new_device( - self.device_name, - model='Platypush virtual device', - manufacturer='platypush', - icon='system', - ) - - self.logger.info(f'Created Pushbullet device {self.device_name}') - return device.device_iden - - def close(self): - if self.listener: - self.listener.close() - self.listener = None - - def on_stop(self): - self.logger.info('Received STOP event on the Pushbullet backend') - super().on_stop() - self.close() - self.logger.info('Pushbullet backend terminated') - - def on_close(self, err=None): - def callback(*_): - self.listener = None - raise RuntimeError(err or 'Connection closed') - - return callback - - def on_error(self, *_): - def callback(*args): - self.logger.error(f'Pushbullet error: {args}') - try: - if self.listener: - self.listener.close() - except Exception as e: - self.logger.error('Error on Pushbullet connection close upon error') - self.logger.exception(e) - finally: - self.listener = None - - return callback - - def on_open(self): - def callback(*_): - self.logger.info('Pushbullet service connected') - - return callback - - def run_listener(self): - from .listener import Listener - - self.logger.info( - f'Initializing Pushbullet backend - device_id: {self.device_name}' - ) - self.listener = Listener( - account=self.pb, - on_push=self.on_push(), - on_open=self.on_open(), - on_close=self.on_close(), - on_error=self.on_error(), - http_proxy_host=self.proxy_host, - http_proxy_port=self.proxy_port, - ) - - self.listener.run_forever() - - def run(self): - super().run() - initialized = False - - while not initialized: - try: - self._initialize() - initialized = True - except Exception as e: - self.logger.exception(e) - self.logger.error(f'Pushbullet initialization error: {e}') - time.sleep(10) - - while not self.should_stop(): - try: - self.run_listener() - except Exception as e: - self.logger.exception(e) - time.sleep(10) - self.logger.info('Retrying connection') - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/pushbullet/manifest.yaml b/platypush/backend/pushbullet/manifest.yaml deleted file mode 100644 index 5640c6c48..000000000 --- a/platypush/backend/pushbullet/manifest.yaml +++ /dev/null @@ -1,16 +0,0 @@ -manifest: - events: - platypush.message.event.pushbullet.PushbulletEvent: if a new push is received - apk: - - git - apt: - - git - dnf: - - git - pacman: - - git - install: - pip: - - git+https://github.com/rbrcsk/pushbullet.py - package: platypush.backend.pushbullet - type: backend diff --git a/platypush/context/__init__.py b/platypush/context/__init__.py index d32b9c41e..91131b7ea 100644 --- a/platypush/context/__init__.py +++ b/platypush/context/__init__.py @@ -74,7 +74,7 @@ def register_backends(bus=None, global_scope=False, **kwargs): for name, cfg in Config.get_backends().items(): module = importlib.import_module('platypush.backend.' + name) - # e.g. backend.pushbullet main class: PushbulletBackend + # e.g. backend.http main class: HttpBackend cls_name = '' for token in module.__name__.title().split('.')[2:]: cls_name += token.title() diff --git a/platypush/message/event/pushbullet.py b/platypush/message/event/pushbullet.py index 28416fa71..5468e0fed 100644 --- a/platypush/message/event/pushbullet.py +++ b/platypush/message/event/pushbullet.py @@ -1,30 +1,184 @@ +from typing import Optional, Union + from platypush.message.event import Event class PushbulletEvent(Event): """ - PushBullet event object. - - If you have configured the PushBullet backend with your account token, - and enabled notification mirroring on the PushBullet app on your mobile - devices, then the backend will trigger a PushbulletEvent whenever - a new notiification hits your mobile, and you can react to that event - through hooks that can, for example, log your notifications on a database, - display them on a dashboard, let the built-in text-to-speech plugin read - them out loud to you if they match the package name of your news app, - display them on your smart watch if they are pictures, and so on. + Base PushBullet event. """ - def __init__(self, *args, **kwargs): - """ Platypush supports by default the PushBullet notification mirror - format, https://docs.pushbullet.com/#mirrored-notifications """ + def __init__( + self, + *args, + notification_id: str, + title: Optional[str] = None, + body: Optional[Union[str, dict, list]] = None, + url: Optional[str] = None, + source_device: Optional[str] = None, + source_user: Optional[str] = None, + target_device: Optional[str] = None, + icon: Optional[str] = None, + created: float, + modified: float, + **kwargs + ): + """ + :param notification_id: Notification ID. + :param title: Notification title. + :param body: Notification body. + :param url: Notification URL. + :param source_device: Source device ID. + :param source_user: Source user ID. + :param target_device: Target device ID. + :param icon: Notification icon. + :param created: Notification creation timestamp. + :param modified: Notification modification timestamp. + :param kwargs: Additional attributes. + """ + super().__init__( + *args, + notification_id=notification_id, + title=title, + body=body, + url=url, + source_device=source_device, + source_user=source_user, + target_device=target_device, + icon=icon, + created=created, + modified=modified, + **kwargs + ) - if 'type' in kwargs: - # Prevent name clash with event type attribute - kwargs['push_type'] = kwargs.pop('type') - super().__init__(*args, **kwargs) +class PushbulletMessageEvent(PushbulletEvent): + """ + Triggered when a new message is received. + """ + + def __init__( + self, + *args, + sender_id: Optional[str] = None, + sender_email: Optional[str] = None, + sender_name: Optional[str] = None, + receiver_id: Optional[str] = None, + receiver_email: Optional[str] = None, + receiver_name: Optional[str] = None, + **kwargs + ): + super().__init__( + *args, + sender_id=sender_id, + sender_email=sender_email, + sender_name=sender_name, + receiver_id=receiver_id, + receiver_email=receiver_email, + receiver_name=receiver_name, + **kwargs + ) + + +class PushbulletNotificationEvent(PushbulletEvent): + """ + Triggered when a notification is mirrored from another device. + """ + + def __init__( + self, + *args, + title: str, + body: str, + dismissible: bool, + application_name: Optional[str] = None, + package_name: Optional[str] = None, + actions: Optional[dict] = None, + **kwargs + ): + """ + :param title: Mirror notification title. + :param body: Mirror notification body. + :param dismissible: True if the notification can be dismissed. + :param application_name: Application name. + :param package_name: Package name. + :param actions: Actions associated to the notification. Example: + + .. code-block:: json + + [ + { + "label": "previous", + "trigger_key": "com.termux.api_0_6107998_previous" + }, + { + "label": "pause", + "trigger_key": "com.termux.api_0_6107998_pause" + }, + { + "label": "play", + "trigger_key": "com.termux.api_0_6107998_play" + }, + { + "label": "next", + "trigger_key": "com.termux.api_0_6107998_next" + } + ] + + """ + super().__init__( + *args, + title=title, + body=body, + dismissible=dismissible, + application_name=application_name, + package_name=package_name, + actions=actions, + **kwargs + ) + + +class PushbulletDismissalEvent(PushbulletEvent): + """ + Triggered when a notification is dismissed. + """ + + def __init__(self, *args, package_name: Optional[str] = None, **kwargs): + super().__init__(*args, package_name=package_name, **kwargs) + + +class PushbulletLinkEvent(PushbulletMessageEvent): + """ + Triggered when a push with a link is received. + """ + + +class PushbulletFileEvent(PushbulletMessageEvent): + """ + Triggered when a push with a file is received. + """ + + def __init__( + self, + *args, + file_name: str, + file_type: str, + file_url: str, + image_width: Optional[int] = None, + image_height: Optional[int] = None, + image_url: Optional[str] = None, + **kwargs + ): + super().__init__( + *args, + file_name=file_name, + file_type=file_type, + file_url=file_url, + image_width=image_width, + image_height=image_height, + image_url=image_url, + **kwargs + ) # vim:sw=4:ts=4:et: - diff --git a/platypush/plugins/pushbullet/__init__.py b/platypush/plugins/pushbullet/__init__.py index ab2c6ab21..89c70a309 100644 --- a/platypush/plugins/pushbullet/__init__.py +++ b/platypush/plugins/pushbullet/__init__.py @@ -1,70 +1,277 @@ +from dataclasses import dataclass import json import os -from typing import Optional +import time +from enum import Enum +from threading import Event, RLock +from typing import Optional, Type import requests -from platypush.context import get_backend -from platypush.plugins import Plugin, action +from platypush.config import Config +from platypush.message.event.pushbullet import ( + PushbulletDismissalEvent, + PushbulletEvent, + PushbulletFileEvent, + PushbulletLinkEvent, + PushbulletMessageEvent, + PushbulletNotificationEvent, +) +from platypush.plugins import RunnablePlugin, action +from platypush.schemas.pushbullet import PushbulletDeviceSchema, PushbulletSchema -class PushbulletPlugin(Plugin): +class PushbulletType(Enum): """ - This plugin allows you to send pushes and files to your PushBullet account. - Note: This plugin will only work if the :mod:`platypush.backend.pushbullet` - backend is configured. - - Requires: - - * The :class:`platypush.backend.pushbullet.PushbulletBackend` backend enabled - + PushBullet event types. """ - def __init__(self, token: Optional[str] = None, **kwargs): + DISMISSAL = 'dismissal' + FILE = 'file' + LINK = 'link' + MESSAGE = 'message' + MIRROR = 'mirror' + NOTE = 'note' + + +@dataclass +class PushbulletEventType: + """ + PushBullet event type. + """ + + type: PushbulletType + event_class: Type[PushbulletEvent] + + +_push_event_types = [ + PushbulletEventType( + type=PushbulletType.DISMISSAL, + event_class=PushbulletDismissalEvent, + ), + PushbulletEventType( + type=PushbulletType.FILE, + event_class=PushbulletFileEvent, + ), + PushbulletEventType( + type=PushbulletType.LINK, + event_class=PushbulletLinkEvent, + ), + PushbulletEventType( + type=PushbulletType.MESSAGE, + event_class=PushbulletMessageEvent, + ), + PushbulletEventType( + type=PushbulletType.MIRROR, + event_class=PushbulletNotificationEvent, + ), + PushbulletEventType( + type=PushbulletType.NOTE, + event_class=PushbulletMessageEvent, + ), +] + +_push_events_by_type = {t.type.value: t for t in _push_event_types} + + +class PushbulletPlugin(RunnablePlugin): + """ + `PushBullet `_ integration. + + Among the other things, this plugin allows you to easily interact with your + mobile devices that have the app installed from Platypush. + + If notification mirroring is enabled on your device, then the push + notifications will be mirrored to Platypush as well as PushBullet events. + + Since PushBullet also comes with a Tasker integration, you can also use this + plugin to send commands to your Android device and trigger actions on it. + It can be used to programmatically send files to your devices and manage + shared clipboards too. + """ + + _timeout = 15.0 + _upload_timeout = 600.0 + + def __init__( + self, + token: str, + device: Optional[str] = None, + enable_mirroring: bool = True, + **kwargs, + ): """ - :param token: Pushbullet API token. If not set the plugin will try to retrieve it from - the Pushbullet backend configuration, if available + :param token: PushBullet API token, see https://docs.pushbullet.com/#authentication. + :param device: Device ID that should be exposed. Default: ``Platypush @ + ``. + :param enable_mirroring: If set to True (default) then the plugin will + receive notifications mirrored from other connected devices - + these will also be rendered on the connected web clients. Disable + it if you don't want to forward your mobile notifications through + the plugin. """ super().__init__(**kwargs) - if not token: - backend = get_backend('pushbullet') - if not backend or not backend.token: - raise AttributeError('No Pushbullet token specified') - - self.token = backend.token - else: - self.token = token + if not device: + device = f'Platypush @ {Config.get_device_id()}' + self.token = token + self.device_name = device + self.enable_mirroring = enable_mirroring + self.listener = None + self._initialized = Event() + self._device = None + self._init_lock = RLock() + self._pb = None + self._device_id = None self._devices = [] self._devices_by_id = {} self._devices_by_name = {} - @action - def get_devices(self): + def _initialize(self): + from pushbullet import Pushbullet + + if self._initialized.is_set(): + return + + self._pb = Pushbullet(self.token) + + try: + self._device = self._pb.get_device(self.device_name) + except Exception as e: + self.logger.info( + 'Device %s does not exist: %s. Creating it', + self.device_name, + e, + ) + self._device = self._pb.new_device(self.device_name) + + self._device_id = self.get_device_id() + self._initialized.set() + + @property + def pb(self): """ - Get the list of available devices + :return: PushBullet API object. """ - resp = requests.get( - 'https://api.pushbullet.com/v2/devices', + with self._init_lock: + self._initialize() + + assert self._pb + return self._pb + + @property + def device(self): + """ + :return: Current PushBullet device object. + """ + with self._init_lock: + self._initialize() + + assert self._device + return self._device + + @property + def device_id(self): + return self.device.device_iden + + def _request(self, method: str, url: str, **kwargs): + meth = getattr(requests, method) + resp = meth( + 'https://api.pushbullet.com/v2/' + url.lstrip('/'), + timeout=self._timeout, headers={ 'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json', }, + **kwargs, ) - self._devices = resp.json().get('devices', []) - self._devices_by_id = {dev['iden']: dev for dev in self._devices} + resp.raise_for_status() + return resp.json() - self._devices_by_name = { - dev['nickname']: dev for dev in self._devices if 'nickname' in dev - } + def get_device_id(self): + assert self._pb - @action - def get_device(self, device) -> Optional[dict]: - """ - :param device: Device ID or name - """ + try: + return self._pb.get_device(self.device_name).device_iden + except Exception: + device = self.pb.new_device( + self.device_name, + model='Platypush virtual device', + manufacturer='platypush', + icon='system', + ) + + self.logger.info('Created Pushbullet device %s', self.device_name) + return device.device_iden + + def _get_latest_push(self): + t = int(time.time()) - 10 + pushes = self.pb.get_pushes(modified_after=str(t), limit=1) + if pushes: + return pushes[0] + + return None + + def on_open(self, *_, **__): + self.logger.info('Pushbullet connected') + + def on_close(self, args=None): + err = args[0] if args else None + self.close() + assert not err or self.should_stop(), 'Pushbullet connection closed: ' + str( + err or 'unknown error' + ) + + def on_error(self, *args): + raise RuntimeError('Pushbullet error: ' + str(args)) + + def on_push(self, data): + try: + # Parse the push + try: + data = json.loads(data) if isinstance(data, str) else data + except Exception as e: + self.logger.exception(e) + return + + # If it's a push, get it + push = None + if data['type'] == 'tickle' and data['subtype'] == 'push': + push = self._get_latest_push() + elif data['type'] == 'push': + push = data['push'] + + if not push: + self.logger.debug('Not a push notification.\nMessage: %s', data) + return + + push_type = push.pop('type', None) + push_event_type = _push_events_by_type.get(push_type) + if not push_event_type: + self.logger.debug( + 'Unknown push type: %s.\nMessage: %s', push_type, data + ) + return + + if ( + not self.enable_mirroring + and push_event_type.type == PushbulletType.MIRROR + ): + return + + push = dict(PushbulletSchema().dump(push)) + evt_type = push_event_type.event_class + self._bus.post(evt_type(**push)) + except Exception as e: + self.logger.warning( + 'Error while processing push: %s.\nMessage: %s', e, data + ) + self.logger.exception(e) + return + + def _get_device(self, device) -> Optional[dict]: output = None refreshed = False @@ -79,6 +286,78 @@ class PushbulletPlugin(Plugin): self.get_devices() refreshed = True + return None + + def close(self): + if self.listener: + try: + self.listener.close() + except Exception: + pass + + self.listener = None + + if self._pb: + self._pb = None + + self._initialized.clear() + + def run_listener(self): + from .listener import Listener + + self.listener = Listener( + account=self.pb, + on_push=self.on_push, + on_open=self.on_open, + on_close=self.on_close, + on_error=self.on_error, + ) + + self.listener.run_forever() + + @action + def get_devices(self): + """ + Get the list of available devices. + + :return: .. schema:: pushbullet.PushbulletDeviceSchema(many=True) + """ + resp = self._request('get', 'devices') + self._devices = resp.get('devices', []) + self._devices_by_id = {dev['iden']: dev for dev in self._devices} + self._devices_by_name = { + dev['nickname']: dev for dev in self._devices if 'nickname' in dev + } + + return PushbulletDeviceSchema(many=True).dump(self._devices) + + @action + def get_device(self, device: str) -> Optional[dict]: + """ + Get a device by ID or name. + + :param device: Device ID or name + :return: .. schema:: pushbullet.PushbulletDeviceSchema + """ + dev = self._get_device(device) + if not dev: + return None + + return dict(PushbulletDeviceSchema().dump(dev)) + + @action + def get_pushes(self, limit: int = 10): + """ + Get the list of pushes. + + :param limit: Maximum number of pushes to fetch (default: 10). + :return: .. schema:: pushbullet.PushbulletSchema(many=True) + """ + return PushbulletSchema().dump( + self._request('get', 'pushes', params={'limit': limit}).get('pushes', []), + many=True, + ) + @action def send_note( self, @@ -96,13 +375,13 @@ class PushbulletPlugin(Plugin): :param title: Note title :param url: URL attached to the note :param kwargs: Push arguments, see https://docs.pushbullet.com/#create-push + :return: .. schema:: pushbullet.PushbulletSchema """ dev = None if device: - dev = self.get_device(device).output - if not dev: - raise RuntimeError(f'No such device: {device}') + dev = self._get_device(device) + assert dev, f'No such device: {device}' kwargs['body'] = body kwargs['title'] = title @@ -114,19 +393,8 @@ class PushbulletPlugin(Plugin): if dev: kwargs['device_iden'] = dev['iden'] - resp = requests.post( - 'https://api.pushbullet.com/v2/pushes', - data=json.dumps(kwargs), - headers={ - 'Authorization': 'Bearer ' + self.token, - 'Content-Type': 'application/json', - }, - ) - - if resp.status_code >= 400: - raise Exception( - f'Pushbullet push failed with status {resp.status_code}: {resp.json()}' - ) + rs = self._request('post', 'pushes', data=json.dumps(kwargs)) + return dict(PushbulletSchema().dump(rs)) @action def send_file(self, filename: str, device: Optional[str] = None): @@ -139,90 +407,85 @@ class PushbulletPlugin(Plugin): dev = None if device: - dev = self.get_device(device).output - if not dev: - raise RuntimeError(f'No such device: {device}') + dev = self._get_device(device) + assert dev, f'No such device: {device}' - resp = requests.post( - 'https://api.pushbullet.com/v2/upload-request', + upload_req = self._request( + 'post', + 'upload-request', data=json.dumps({'file_name': os.path.basename(filename)}), - headers={ - 'Authorization': 'Bearer ' + self.token, - 'Content-Type': 'application/json', - }, ) - if resp.status_code != 200: - raise Exception( - f'Pushbullet file upload request failed with status {resp.status_code}' - ) - - r = resp.json() with open(filename, 'rb') as f: - resp = requests.post(r['upload_url'], data=r['data'], files={'file': f}) - - if resp.status_code != 204: - raise Exception( - f'Pushbullet file upload failed with status {resp.status_code}' + rs = requests.post( + upload_req['upload_url'], + data=upload_req['data'], + files={'file': f}, + timeout=self._upload_timeout, ) - resp = requests.post( - 'https://api.pushbullet.com/v2/pushes', - headers={ - 'Authorization': 'Bearer ' + self.token, - 'Content-Type': 'application/json', - }, + rs.raise_for_status() + self._request( + 'post', + 'pushes', data=json.dumps( { 'type': 'file', 'device_iden': dev['iden'] if dev else None, - 'file_name': r['file_name'], - 'file_type': r['file_type'], - 'file_url': r['file_url'], + 'file_name': upload_req['file_name'], + 'file_type': upload_req.get('file_type'), + 'file_url': upload_req['file_url'], } ), ) - if resp.status_code >= 400: - raise Exception( - f'Pushbullet file push failed with status {resp.status_code}' - ) - return { - 'filename': r['file_name'], - 'type': r['file_type'], - 'url': r['file_url'], + 'filename': upload_req['file_name'], + 'type': upload_req.get('file_type'), + 'url': upload_req['file_url'], } @action def send_clipboard(self, text: str): """ - Copy text to the clipboard of a device. + Send text to the clipboard of other devices. :param text: Text to be copied. """ - backend = get_backend('pushbullet') - device_id = backend.get_device_id() if backend else None - - resp = requests.post( - 'https://api.pushbullet.com/v2/ephemerals', + self._request( + 'post', + 'ephemerals', data=json.dumps( { 'type': 'push', 'push': { 'body': text, 'type': 'clip', - 'source_device_iden': device_id, + 'source_device_iden': self.device_id, }, } ), - headers={ - 'Authorization': 'Bearer ' + self.token, - 'Content-Type': 'application/json', - }, ) - resp.raise_for_status() + def main(self): + while not self.should_stop(): + while not self._initialized.is_set(): + try: + self._initialize() + except Exception as e: + self.logger.exception(e) + self.logger.error('Pushbullet initialization error: %s', e) + self.wait_stop(10) + + while not self.should_stop(): + try: + self.run_listener() + except Exception as e: + if not self.should_stop(): + self.logger.exception(e) + self.logger.error('Pushbullet listener error: %s', e) + + self.wait_stop(10) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/pushbullet/listener.py b/platypush/plugins/pushbullet/listener.py similarity index 76% rename from platypush/backend/pushbullet/listener.py rename to platypush/plugins/pushbullet/listener.py index 1c3c4b96a..796d0d6c6 100644 --- a/platypush/backend/pushbullet/listener.py +++ b/platypush/plugins/pushbullet/listener.py @@ -9,11 +9,14 @@ class Listener(_Listener): """ Extends the Pushbullet Listener object by adding ``on_open`` and ``on_close`` handlers. """ - def __init__(self, - *args, - on_open: Optional[Callable[[], None]] = None, - on_close: Optional[Callable[[], None]] = None, - **kwargs): + + def __init__( + self, + *args, + on_open: Optional[Callable[[], None]] = None, + on_close: Optional[Callable[[], None]] = None, + **kwargs, + ): super().__init__(*args, **kwargs) self._on_open_hndl = on_open self._on_close_hndl = on_close @@ -35,7 +38,7 @@ class Listener(_Listener): try: self._on_close_hndl() except Exception as e: - self.logger.warning(f'Pushbullet listener close error: {e}') + self.logger.warning('Pushbullet listener close error: %s', e) return callback diff --git a/platypush/plugins/pushbullet/manifest.yaml b/platypush/plugins/pushbullet/manifest.yaml index 4e334226f..8c5fa0929 100644 --- a/platypush/plugins/pushbullet/manifest.yaml +++ b/platypush/plugins/pushbullet/manifest.yaml @@ -1,6 +1,21 @@ manifest: - events: {} - install: - pip: [] package: platypush.plugins.pushbullet type: plugin + events: + - platypush.message.event.pushbullet.PushbulletDismissalEvent + - platypush.message.event.pushbullet.PushbulletFileEvent + - platypush.message.event.pushbullet.PushbulletLinkEvent + - platypush.message.event.pushbullet.PushbulletMessageEvent + - platypush.message.event.pushbullet.PushbulletMessageEvent + - platypush.message.event.pushbullet.PushbulletNotificationEvent + install: + apk: + - git + apt: + - git + dnf: + - git + pacman: + - git + pip: + - git+https://github.com/rbrcsk/pushbullet.py diff --git a/platypush/schemas/pushbullet.py b/platypush/schemas/pushbullet.py new file mode 100644 index 000000000..c6d88625e --- /dev/null +++ b/platypush/schemas/pushbullet.py @@ -0,0 +1,324 @@ +import json + +from marshmallow import EXCLUDE, fields, pre_dump +from marshmallow.schema import Schema + +from platypush.schemas import DateTime + + +class PushbulletActionSchema(Schema): + """ + Schema for Pushbullet notification actions. + """ + + label = fields.String( + required=True, + metadata={ + 'description': 'Label of the action', + 'example': 'Example action', + }, + ) + + trigger_key = fields.String( + required=True, + metadata={ + 'description': 'Key of the action', + 'example': 'example_action', + }, + ) + + +class PushbulletSchema(Schema): + """ + Schema for Pushbullet API messages. + """ + + # pylint: disable=too-few-public-methods + class Meta: # type: ignore + """ + Exclude unknown fields. + """ + + unknown = EXCLUDE + + notification_id = fields.String( + required=True, + metadata={ + 'description': 'Unique identifier for the notification/message', + 'example': '12345', + }, + ) + + title = fields.String( + metadata={ + 'description': 'Title of the notification/message', + 'example': 'Hello world', + }, + ) + + body = fields.Raw( + metadata={ + 'description': 'Body of the notification/message', + 'example': 'Example body', + }, + ) + + url = fields.Url( + metadata={ + 'description': 'URL attached to the notification/message', + 'example': 'https://example.com', + }, + ) + + source_user = fields.String( + attribute='source_user_iden', + metadata={ + 'description': 'Source user of the notification/message', + 'example': 'user123', + }, + ) + + source_device = fields.String( + attribute='source_device_iden', + metadata={ + 'description': 'Source device of the notification/message', + 'example': 'device123', + }, + ) + + target_device = fields.String( + attribute='target_device_iden', + metadata={ + 'description': 'Target device of the notification/message', + 'example': 'device456', + }, + ) + + sender_id = fields.String( + attribute='sender_iden', + metadata={ + 'description': 'Sender ID of the notification/message', + 'example': '12345', + }, + ) + + sender_email = fields.Email( + attribute='sender_email_normalized', + metadata={ + 'description': 'Sender email of the notification/message', + 'example': 'user1@example.com', + }, + ) + + sender_name = fields.String( + metadata={ + 'description': 'Sender name of the notification/message', + 'example': 'John Doe', + }, + ) + + receiver_id = fields.String( + attribute='receiver_iden', + metadata={ + 'description': 'Receiver ID of the notification/message', + 'example': '12346', + }, + ) + + receiver_email = fields.Email( + attribute='receiver_email_normalized', + metadata={ + 'description': 'Receiver email of the notification/message', + 'example': 'user2@example.com', + }, + ) + + dismissible = fields.Boolean( + metadata={ + 'description': 'Whether the notification/message is dismissible', + 'example': True, + }, + ) + + icon = fields.String( + metadata={ + 'description': 'Base64 encoded icon of the notification/message', + 'example': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABpUlEQVQ4T', + }, + ) + + application_name = fields.String( + metadata={ + 'description': 'Name of the application that sent the notification/message', + 'example': 'Example app', + }, + ) + + package_name = fields.String( + metadata={ + 'description': 'Package name of the application that sent the notification/message', + 'example': 'com.example.app', + }, + ) + + file_name = fields.String( + metadata={ + 'description': 'Name of the file attached to the notification/message', + 'example': 'example.txt', + }, + ) + + file_type = fields.String( + metadata={ + 'description': 'Type of the file attached to the notification/message', + 'example': 'text/plain', + }, + ) + + file_url = fields.Url( + metadata={ + 'description': 'URL of the file attached to the notification/message', + 'example': 'https://example.com/example.txt', + }, + ) + + image_width = fields.Integer( + metadata={ + 'description': 'Width of the image attached to the notification/message', + 'example': 100, + }, + ) + + image_height = fields.Integer( + metadata={ + 'description': 'Height of the image attached to the notification/message', + 'example': 100, + }, + ) + + image_url = fields.Url( + metadata={ + 'description': 'URL of the image attached to the notification/message', + 'example': 'https://example.com/example.png', + }, + ) + + actions = fields.Nested( + PushbulletActionSchema, + many=True, + metadata={ + 'description': 'Actions of the notification/message', + }, + ) + + created = DateTime( + metadata={ + 'description': 'Creation timestamp of the notification/message', + 'example': '2021-01-01T00:00:00', + }, + ) + + modified = DateTime( + metadata={ + 'description': 'Last modification timestamp of the notification/message', + 'example': '2021-01-01T00:00:00', + }, + ) + + @pre_dump + def pre_dump(self, data, **_): + """ + Pre-dump hook. + """ + + data['notification_id'] = str( + data.pop('iden', data.pop('notification_id', None)) + ) + + if data.get('body') is not None: + try: + data['body'] = json.loads(data['body']) + except (TypeError, ValueError): + pass + + return data + + +class PushbulletDeviceSchema(Schema): + """ + Schema for Pushbullet devices. + """ + + active = fields.Boolean( + metadata={ + 'description': 'Whether the device is active', + 'example': True, + }, + ) + + device_id = fields.String( + required=True, + attribute='iden', + metadata={ + 'description': 'Unique identifier for the device', + 'example': '12345', + }, + ) + + name = fields.String( + attribute='nickname', + metadata={ + 'description': 'Name of the device', + 'example': 'Example device', + }, + ) + + kind = fields.String( + metadata={ + 'description': 'Kind of the device', + 'example': 'android', + }, + ) + + manufacturer = fields.String( + metadata={ + 'description': 'Manufacturer of the device', + 'example': 'Example manufacturer', + }, + ) + + model = fields.String( + metadata={ + 'description': 'Model of the device', + 'example': 'Example model', + }, + ) + + icon = fields.String( + metadata={ + 'description': 'Device icon type', + 'example': 'system', + }, + ) + + pushable = fields.Boolean( + metadata={ + 'description': 'Whether it is possible to push notifications and ' + 'messages to the device', + 'example': True, + }, + ) + + created = DateTime( + metadata={ + 'description': 'Creation timestamp of the device', + 'example': '2021-01-01T00:00:00', + }, + ) + + modified = DateTime( + metadata={ + 'description': 'Last modification timestamp of the device', + 'example': '2021-01-01T00:00:00', + }, + ) diff --git a/setup.py b/setup.py index f3fc4fd83..c597e4294 100755 --- a/setup.py +++ b/setup.py @@ -91,7 +91,7 @@ setup( extras_require={ # Support for Kafka backend and plugin 'kafka': ['kafka-python'], - # Support for Pushbullet backend and plugin + # Support for Pushbullet 'pushbullet': [ 'pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master' ],