forked from platypush/platypush
[#302] Merged pushbullet
backend and plugin.
Also, added support for more granular Pushbullet events. Closes: #302
This commit is contained in:
parent
4e1943d197
commit
9fa5989e21
13 changed files with 905 additions and 374 deletions
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
``pushbullet``
|
||||
================================
|
||||
|
||||
.. automodule:: platypush.backend.pushbullet
|
||||
:members:
|
||||
|
|
@ -6,12 +6,10 @@
|
|||
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,
|
||||
|
@ -19,13 +17,16 @@ export default {
|
|||
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'
|
||||
)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -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:
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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:
|
||||
"""
|
||||
:param token: Pushbullet API token. If not set the plugin will try to retrieve it from
|
||||
the Pushbullet backend configuration, if available
|
||||
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 <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)
|
||||
|
||||
if not token:
|
||||
backend = get_backend('pushbullet')
|
||||
if not backend or not backend.token:
|
||||
raise AttributeError('No Pushbullet token specified')
|
||||
if not device:
|
||||
device = f'Platypush @ {Config.get_device_id()}'
|
||||
|
||||
self.token = backend.token
|
||||
else:
|
||||
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:
|
||||
|
|
|
@ -9,11 +9,14 @@ class Listener(_Listener):
|
|||
"""
|
||||
Extends the Pushbullet Listener object by adding ``on_open`` and ``on_close`` handlers.
|
||||
"""
|
||||
def __init__(self,
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
on_open: Optional[Callable[[], None]] = None,
|
||||
on_close: Optional[Callable[[], None]] = None,
|
||||
**kwargs):
|
||||
**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
|
||||
|
|
@ -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
|
||||
|
|
324
platypush/schemas/pushbullet.py
Normal file
324
platypush/schemas/pushbullet.py
Normal 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',
|
||||
},
|
||||
)
|
2
setup.py
2
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'
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue