[#302] Merged pushbullet backend and plugin.

Also, added support for more granular Pushbullet events.

Closes: #302
This commit is contained in:
Fabio Manganiello 2024-01-10 00:41:51 +01:00
parent 4e1943d197
commit 9fa5989e21
13 changed files with 905 additions and 374 deletions

View file

@ -419,9 +419,7 @@ backend](https://docs.platypush.tech/en/latest/platypush/backend/http.html), an
[MQTT [MQTT
instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a instance](https://docs.platypush.tech/en/latest/platypush/backend/mqtt.html), a
[Kafka [Kafka
instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html), instance](https://docs.platypush.tech/en/latest/platypush/backend/kafka.html).
[Pushbullet](https://docs.platypush.tech/en/latest/platypush/backend/pushbullet.html)
etc.).
If a backend supports the execution of requests (e.g. HTTP, MQTT, Kafka, 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. Websocket and TCP) then you can send requests to these services in JSON format.

View file

@ -23,7 +23,6 @@ Backends
platypush/backend/nextcloud.rst platypush/backend/nextcloud.rst
platypush/backend/nfc.rst platypush/backend/nfc.rst
platypush/backend/nodered.rst platypush/backend/nodered.rst
platypush/backend/pushbullet.rst
platypush/backend/redis.rst platypush/backend/redis.rst
platypush/backend/scard.rst platypush/backend/scard.rst
platypush/backend/sensor.ir.zeroborg.rst platypush/backend/sensor.ir.zeroborg.rst

View file

@ -1,6 +0,0 @@
``pushbullet``
================================
.. automodule:: platypush.backend.pushbullet
:members:

View file

@ -6,12 +6,10 @@
import Utils from "@/Utils"; import Utils from "@/Utils";
export default { export default {
name: "Pushbullet",
mixins: [Utils], mixins: [Utils],
methods: { methods: {
onMessage(event) { onMessage(event) {
if (event.push_type === 'mirror') {
this.notify({ this.notify({
title: event.title, title: event.title,
text: event.body, text: event.body,
@ -19,13 +17,16 @@ export default {
src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined, src: event.icon ? 'data:image/png;base64, ' + event.icon : undefined,
icon: event.icon ? undefined : 'bell', icon: event.icon ? undefined : 'bell',
}, },
}); })
}
}, },
}, },
mounted() { mounted() {
this.subscribe(this.onMessage, null, 'platypush.message.event.pushbullet.PushbulletEvent') this.subscribe(
this.onMessage,
null,
'platypush.message.event.pushbullet.PushbulletNotificationEvent'
)
}, },
} }
</script> </script>

View file

@ -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:

View file

@ -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

View file

@ -74,7 +74,7 @@ def register_backends(bus=None, global_scope=False, **kwargs):
for name, cfg in Config.get_backends().items(): for name, cfg in Config.get_backends().items():
module = importlib.import_module('platypush.backend.' + name) module = importlib.import_module('platypush.backend.' + name)
# e.g. backend.pushbullet main class: PushbulletBackend # e.g. backend.http main class: HttpBackend
cls_name = '' cls_name = ''
for token in module.__name__.title().split('.')[2:]: for token in module.__name__.title().split('.')[2:]:
cls_name += token.title() cls_name += token.title()

View file

@ -1,30 +1,184 @@
from typing import Optional, Union
from platypush.message.event import Event from platypush.message.event import Event
class PushbulletEvent(Event): class PushbulletEvent(Event):
""" """
PushBullet event object. Base PushBullet event.
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.
""" """
def __init__(self, *args, **kwargs): def __init__(
""" Platypush supports by default the PushBullet notification mirror self,
format, https://docs.pushbullet.com/#mirrored-notifications """ *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: # vim:sw=4:ts=4:et:

View file

@ -1,70 +1,277 @@
from dataclasses import dataclass
import json import json
import os import os
from typing import Optional import time
from enum import Enum
from threading import Event, RLock
from typing import Optional, Type
import requests import requests
from platypush.context import get_backend from platypush.config import Config
from platypush.plugins import Plugin, action 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. PushBullet event types.
Note: This plugin will only work if the :mod:`platypush.backend.pushbullet`
backend is configured.
Requires:
* The :class:`platypush.backend.pushbullet.PushbulletBackend` backend enabled
""" """
def __init__(self, token: Optional[str] = None, **kwargs): DISMISSAL = 'dismissal'
FILE = 'file'
LINK = 'link'
MESSAGE = 'message'
MIRROR = 'mirror'
NOTE = 'note'
@dataclass
class PushbulletEventType:
""" """
:param token: Pushbullet API token. If not set the plugin will try to retrieve it from PushBullet event type.
the Pushbullet backend configuration, if available """
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 <https://www.pushbullet.com/>`_ 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, see https://docs.pushbullet.com/#authentication.
:param device: Device ID that should be exposed. Default: ``Platypush @
<device_id | hostname>``.
: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) super().__init__(**kwargs)
if not token: if not device:
backend = get_backend('pushbullet') device = f'Platypush @ {Config.get_device_id()}'
if not backend or not backend.token:
raise AttributeError('No Pushbullet token specified')
self.token = backend.token
else:
self.token = token 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 = []
self._devices_by_id = {} self._devices_by_id = {}
self._devices_by_name = {} self._devices_by_name = {}
@action def _initialize(self):
def get_devices(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( with self._init_lock:
'https://api.pushbullet.com/v2/devices', 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={ headers={
'Authorization': 'Bearer ' + self.token, 'Authorization': 'Bearer ' + self.token,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
**kwargs,
) )
self._devices = resp.json().get('devices', []) resp.raise_for_status()
self._devices_by_id = {dev['iden']: dev for dev in self._devices} return resp.json()
self._devices_by_name = { def get_device_id(self):
dev['nickname']: dev for dev in self._devices if 'nickname' in dev assert self._pb
}
@action try:
def get_device(self, device) -> Optional[dict]: return self._pb.get_device(self.device_name).device_iden
""" except Exception:
:param device: Device ID or name 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 output = None
refreshed = False refreshed = False
@ -79,6 +286,78 @@ class PushbulletPlugin(Plugin):
self.get_devices() self.get_devices()
refreshed = True 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 @action
def send_note( def send_note(
self, self,
@ -96,13 +375,13 @@ class PushbulletPlugin(Plugin):
:param title: Note title :param title: Note title
:param url: URL attached to the note :param url: URL attached to the note
:param kwargs: Push arguments, see https://docs.pushbullet.com/#create-push :param kwargs: Push arguments, see https://docs.pushbullet.com/#create-push
:return: .. schema:: pushbullet.PushbulletSchema
""" """
dev = None dev = None
if device: if device:
dev = self.get_device(device).output dev = self._get_device(device)
if not dev: assert dev, f'No such device: {device}'
raise RuntimeError(f'No such device: {device}')
kwargs['body'] = body kwargs['body'] = body
kwargs['title'] = title kwargs['title'] = title
@ -114,19 +393,8 @@ class PushbulletPlugin(Plugin):
if dev: if dev:
kwargs['device_iden'] = dev['iden'] kwargs['device_iden'] = dev['iden']
resp = requests.post( rs = self._request('post', 'pushes', data=json.dumps(kwargs))
'https://api.pushbullet.com/v2/pushes', return dict(PushbulletSchema().dump(rs))
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()}'
)
@action @action
def send_file(self, filename: str, device: Optional[str] = None): def send_file(self, filename: str, device: Optional[str] = None):
@ -139,90 +407,85 @@ class PushbulletPlugin(Plugin):
dev = None dev = None
if device: if device:
dev = self.get_device(device).output dev = self._get_device(device)
if not dev: assert dev, f'No such device: {device}'
raise RuntimeError(f'No such device: {device}')
resp = requests.post( upload_req = self._request(
'https://api.pushbullet.com/v2/upload-request', 'post',
'upload-request',
data=json.dumps({'file_name': os.path.basename(filename)}), 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: with open(filename, 'rb') as f:
resp = requests.post(r['upload_url'], data=r['data'], files={'file': f}) rs = requests.post(
upload_req['upload_url'],
if resp.status_code != 204: data=upload_req['data'],
raise Exception( files={'file': f},
f'Pushbullet file upload failed with status {resp.status_code}' timeout=self._upload_timeout,
) )
resp = requests.post( rs.raise_for_status()
'https://api.pushbullet.com/v2/pushes', self._request(
headers={ 'post',
'Authorization': 'Bearer ' + self.token, 'pushes',
'Content-Type': 'application/json',
},
data=json.dumps( data=json.dumps(
{ {
'type': 'file', 'type': 'file',
'device_iden': dev['iden'] if dev else None, 'device_iden': dev['iden'] if dev else None,
'file_name': r['file_name'], 'file_name': upload_req['file_name'],
'file_type': r['file_type'], 'file_type': upload_req.get('file_type'),
'file_url': r['file_url'], 'file_url': upload_req['file_url'],
} }
), ),
) )
if resp.status_code >= 400:
raise Exception(
f'Pushbullet file push failed with status {resp.status_code}'
)
return { return {
'filename': r['file_name'], 'filename': upload_req['file_name'],
'type': r['file_type'], 'type': upload_req.get('file_type'),
'url': r['file_url'], 'url': upload_req['file_url'],
} }
@action @action
def send_clipboard(self, text: str): 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. :param text: Text to be copied.
""" """
backend = get_backend('pushbullet') self._request(
device_id = backend.get_device_id() if backend else None 'post',
'ephemerals',
resp = requests.post(
'https://api.pushbullet.com/v2/ephemerals',
data=json.dumps( data=json.dumps(
{ {
'type': 'push', 'type': 'push',
'push': { 'push': {
'body': text, 'body': text,
'type': 'clip', '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: # vim:sw=4:ts=4:et:

View file

@ -9,11 +9,14 @@ class Listener(_Listener):
""" """
Extends the Pushbullet Listener object by adding ``on_open`` and ``on_close`` handlers. Extends the Pushbullet Listener object by adding ``on_open`` and ``on_close`` handlers.
""" """
def __init__(self,
def __init__(
self,
*args, *args,
on_open: Optional[Callable[[], None]] = None, on_open: Optional[Callable[[], None]] = None,
on_close: Optional[Callable[[], None]] = None, on_close: Optional[Callable[[], None]] = None,
**kwargs): **kwargs,
):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._on_open_hndl = on_open self._on_open_hndl = on_open
self._on_close_hndl = on_close self._on_close_hndl = on_close
@ -35,7 +38,7 @@ class Listener(_Listener):
try: try:
self._on_close_hndl() self._on_close_hndl()
except Exception as e: except Exception as e:
self.logger.warning(f'Pushbullet listener close error: {e}') self.logger.warning('Pushbullet listener close error: %s', e)
return callback return callback

View file

@ -1,6 +1,21 @@
manifest: manifest:
events: {}
install:
pip: []
package: platypush.plugins.pushbullet package: platypush.plugins.pushbullet
type: plugin 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

View file

@ -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',
},
)

View file

@ -91,7 +91,7 @@ setup(
extras_require={ extras_require={
# Support for Kafka backend and plugin # Support for Kafka backend and plugin
'kafka': ['kafka-python'], 'kafka': ['kafka-python'],
# Support for Pushbullet backend and plugin # Support for Pushbullet
'pushbullet': [ 'pushbullet': [
'pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master' 'pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master'
], ],