From 5f3e9462cd33090cb2e97804dda9aadbcd8f5972 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 30 Mar 2020 20:13:35 -0400 Subject: [PATCH] MacOS X Terminal Notifier Support (#221) --- README.md | 11 +- apprise/plugins/NotifyMacOSX.py | 230 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 8 +- setup.py | 10 +- test/test_macosx_plugin.py | 158 ++++++++++++++++++ 5 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 apprise/plugins/NotifyMacOSX.py create mode 100644 test/test_macosx_plugin.py diff --git a/README.md b/README.md index a2f221a4..21212503 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,11 @@ The table below identifies the services this tool supports and some example serv | -------------------- | ---------- | ------------ | -------------- | | [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token -| [Dbus](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [Enigma2](https://github.com/caronc/apprise/wiki/Notify_enigma2) | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname | [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken | [Flock](https://github.com/caronc/apprise/wiki/Notify_flock) | flock:// | (TCP) 443 | flock://token
flock://botname@token
flock://app_token/u:userid
flock://app_token/g:channel_id
flock://app_token/u:userid/g:channel_id | [Gitter](https://github.com/caronc/apprise/wiki/Notify_gitter) | gitter:// | (TCP) 443 | gitter://token/room
gitter://token/room1/room2/roomN -| [Gnome](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome:// | [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high | [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 | [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 @@ -72,7 +70,6 @@ The table below identifies the services this tool supports and some example serv | [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel | [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname
xmpp://user:password@hostname
xmpps://user:password@hostname:port?jid=user@hostname/resource
xmpps://password@hostname/target@myhost, target2@myhost/resource -| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows:// | [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token | [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Channel
zulip://botname@Organization/Token/Email @@ -90,6 +87,14 @@ The table below identifies the services this tool supports and some example serv | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +## Desktop Notification Support +| Notification Service | Service ID | Default Port | Example Syntax | +| -------------------- | ---------- | ------------ | -------------- | +| [Linux DBus Notifications](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// +| [Linux Gnome Notifications](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome:// +| [MacOS X Notifications](https://github.com/caronc/apprise/wiki/Notify_macosx) | macosx:// | n/a | macosx:// +| [Windows Notifications](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows:// + ### Email Support | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | diff --git a/apprise/plugins/NotifyMacOSX.py b/apprise/plugins/NotifyMacOSX.py new file mode 100644 index 00000000..4fde500b --- /dev/null +++ b/apprise/plugins/NotifyMacOSX.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import absolute_import +from __future__ import print_function + +import platform +import subprocess +import os + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyMacOSX(NotifyBase): + """ + A wrapper for the MacOS X terminal-notifier tool + + Source: https://github.com/julienXX/terminal-notifier + """ + + # The default descriptive name associated with the Notification + service_name = 'MacOSX Notification' + + # The default protocol + protocol = 'macosx' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_macosx' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # Disable throttle rate for MacOSX requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # The path to the terminal-notifier + notify_path = '/usr/local/bin/terminal-notifier' + + # Define object templates + templates = ( + '{schema}://_/', + ) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + # Play the NAME sound when the notification appears. + # Sound names are listed in Sound Preferences. + # Use 'default' for the default sound. + 'sound': { + 'name': _('Sound'), + 'type': 'string', + }, + }) + + def __init__(self, sound=None, include_image=True, **kwargs): + """ + Initialize MacOSX Object + """ + + super(NotifyMacOSX, self).__init__(**kwargs) + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + self._enabled = False + if platform.system() == 'Darwin': + # Check this is Mac OS X 10.8, or higher + major, minor = platform.mac_ver()[0].split('.')[:2] + + # Toggle our _enabled flag if verion is correct and executable + # found. This is done in such a way to provide verbosity to the + # end user so they know why it may or may not work for them. + if not (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)): + self.logger.warning( + "MacOSX Notifications require your OS to be at least " + "v10.8 (detected {}.{})".format(major, minor)) + + elif not os.access(self.notify_path, os.X_OK): + self.logger.warning( + "MacOSX Notifications require '{}' to be in place." + .format(self.notify_path)) + + else: + # We're good to go + self._enabled = True + + # Set sound object (no q/a for now) + self.sound = sound + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform MacOSX Notification + """ + + if not self._enabled: + self.logger.warning( + "MacOSX Notifications are not supported by this system.") + return False + + # Start with our notification path + cmd = [ + self.notify_path, + '-message', body, + ] + + # Title is an optional switch + if title: + cmd.extend(['-title', title]) + + # The sound to play + if self.sound: + cmd.extend(['-sound', self.sound]) + + # Support any defined images if set + image_path = None if not self.include_image \ + else self.image_url(notify_type) + if image_path: + cmd.extend(['-appIcon', image_path]) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Send our notification + output = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + # Wait for process to complete + output.wait() + + if output.returncode: + self.logger.warning('Failed to send MacOSX notification.') + self.logger.exception('MacOSX Exception') + return False + + self.logger.info('Sent MacOSX notification.') + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'image': 'yes' if self.include_image else 'no', + } + + if self.sound: + # Store our sound + args['sound'] = self.sound + + return '{schema}://_/?{args}'.format( + schema=self.protocol, + args=NotifyMacOSX.urlencode(args), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + gnome:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url) + if not results: + results = { + 'schema': NotifyMacOSX.protocol, + 'user': None, + 'password': None, + 'port': None, + 'host': '_', + 'fullpath': None, + 'path': None, + 'url': url, + 'qsd': {}, + } + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # Support 'sound' + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = NotifyMacOSX.unquote(results['qsd']['sound']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index e3e18fd5..bd904195 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,10 +50,10 @@ it easy to access: Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, Kavenegar, KODI, Kumulos, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Nextcloud, -Notica, Notifico, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, -Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, Super -Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, -XMPP, Webex Teams} +Notica, Notifico, Notify MacOSX, MyAndroid, Prowl, Pushalot, PushBullet, +Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, +Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, +XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.8.5 diff --git a/setup.py b/setup.py index b70f8026..66e601c5 100755 --- a/setup.py +++ b/setup.py @@ -71,11 +71,11 @@ setup( url='https://github.com/caronc/apprise', keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend ' 'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join ' - 'Kavenegar KODI Kumulos Mailgun Matrix Mattermost MessageBird MSG91 ' - 'Nexmo Nextcloud Notica, Notifico Prowl PushBullet Pushjet Pushed ' - 'Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush Sinch Slack ' - 'Stride Syslog Techulus Push Telegram Twilio Twist Twitter XBMC ' - 'Microsoft MSTeams Windows Webex CLI API', + 'Kavenegar KODI Kumulos MacOS Mailgun Matrix Mattermost MessageBird ' + 'MSG91 Nexmo Nextcloud Notica, Notifico Prowl PushBullet Pushjet ' + 'Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush ' + 'Sinch Slack Stride Syslog Techulus Push Telegram Twilio Twist ' + 'Twitter XBMC Microsoft MSTeams Windows Webex CLI API', author='Chris Caron', author_email='lead2gold@gmail.com', packages=find_packages(), diff --git a/test/test_macosx_plugin.py b/test/test_macosx_plugin.py new file mode 100644 index 00000000..24ac7f54 --- /dev/null +++ b/test/test_macosx_plugin.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import six +import mock + +import apprise + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +@mock.patch('subprocess.Popen') +@mock.patch('platform.system') +@mock.patch('platform.mac_ver') +def test_macosx_plugin(mock_macver, mock_system, mock_popen, tmpdir): + """ + API: NotifyMacOSX Plugin() + + """ + + # Create a temporary binary file we can reference + script = tmpdir.join("terminal-notifier") + script.write('') + # Give execute bit + os.chmod(str(script), 0o755) + # Point our object to our new temporary existing file + apprise.plugins.NotifyMacOSX.notify_path = str(script) + mock_cmd_response = mock.Mock() + + # Set a successful response + mock_cmd_response.returncode = 0 + + # Simulate a Mac Environment + mock_system.return_value = 'Darwin' + mock_macver.return_value = ('10.8', ('', '', ''), '') + mock_popen.return_value = mock_cmd_response + + obj = apprise.Apprise.instantiate( + 'macosx://_/?image=True', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is True + + # Test url() call + assert isinstance(obj.url(), six.string_types) is True + + # test notifications + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True + + # test notification without a title + assert obj.notify(title='', body='body', + notify_type=apprise.NotifyType.INFO) is True + + obj = apprise.Apprise.instantiate( + 'macosx://_/?image=True', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is True + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True + + obj = apprise.Apprise.instantiate( + 'macosx://_/?image=False', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is True + assert isinstance(obj.url(), six.string_types) is True + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True + + # Test Sound + obj = apprise.Apprise.instantiate( + 'macosx://_/?sound=default', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is True + assert obj.sound == 'default' + assert isinstance(obj.url(), six.string_types) is True + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is True + + # Now test cases where our environment isn't set up correctly + # In this case we'll simulate a situation where our binary isn't + # executable + os.chmod(str(script), 0o644) + obj = apprise.Apprise.instantiate( + 'macosx://_/?sound=default', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is False + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + # Restore permission + os.chmod(str(script), 0o755) + + # Test case where we simply aren't on a mac + mock_system.return_value = 'Linux' + obj = apprise.Apprise.instantiate( + 'macosx://_/?sound=default', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is False + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + # Restore mac environment + mock_system.return_value = 'Darwin' + + # Now we must be Mac OS v10.8 or higher... Test cases where we aren't + mock_macver.return_value = ('10.7', ('', '', ''), '') + obj = apprise.Apprise.instantiate( + 'macosx://_/?sound=default', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is False + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + # Restore valid mac environment + mock_macver.return_value = ('10.8', ('', '', ''), '') + + mock_macver.return_value = ('9.12', ('', '', ''), '') + obj = apprise.Apprise.instantiate( + 'macosx://_/?sound=default', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is False + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + # Restore valid mac environment + mock_macver.return_value = ('10.8', ('', '', ''), '') + + # Test cases where the script just flat out fails + mock_cmd_response.returncode = 1 + obj = apprise.Apprise.instantiate( + 'macosx://', suppress_exceptions=False) + assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True + assert obj._enabled is True + assert obj.notify(title='title', body='body', + notify_type=apprise.NotifyType.INFO) is False + # Restore script return value + mock_cmd_response.returncode = 1