diff --git a/README.md b/README.md index 3c3c54bb..9c43c41d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [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 | [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken | [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 | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ diff --git a/apprise/AppriseAsset.py b/apprise/AppriseAsset.py index 691ca05d..5353357a 100644 --- a/apprise/AppriseAsset.py +++ b/apprise/AppriseAsset.py @@ -33,6 +33,15 @@ class AppriseAsset(object): URL masks. """ + # Application Identifier + app_id = 'Apprise' + + # Application Description + app_desc = 'Apprise Notifications' + + # Provider URL + app_url = 'https://github.com/caronc/apprise' + # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, # there should be a mapping to it's color here: html_notify_map = { @@ -52,6 +61,10 @@ class AppriseAsset(object): image_url_mask = \ 'http://nuxref.com/apprise/themes/{THEME}/apprise-{TYPE}-{XY}.png' + # Application Logo + image_url_logo = \ + 'http://nuxref.com/apprise/themes/{THEME}/apprise-logo.png' + # Image Path Mask image_path_mask = abspath(join( dirname(__file__), @@ -76,20 +89,48 @@ class AppriseAsset(object): if image_url_mask is not None: self.image_url_mask = image_url_mask - def html_color(self, notify_type): + def color(self, notify_type, color_type=None): """ Returns an HTML mapped color based on passed in notify type + + if color_type is: + None then a standard hex string is returned as + a string format ('#000000'). + + int then the integer representation is returned + tuple then the the red, green, blue is returned in a tuple + """ + # Attempt to get the type, otherwise return a default grey # if we couldn't look up the entry - return self.html_notify_map.get(notify_type, self.default_html_color) + color = self.html_notify_map.get(notify_type, self.default_html_color) + if color_type is None: + # This is the default return type + return color - def image_url(self, notify_type, image_size): + elif color_type is int: + # Convert the color to integer + return AppriseAsset.hex_to_int(color) + + # The only other type is tuple + elif color_type is tuple: + return AppriseAsset.hex_to_rgb(color) + + # Unsupported type + raise ValueError( + 'AppriseAsset html_color(): An invalid color_type was specified.') + + def image_url(self, notify_type, image_size, logo=False): """ Apply our mask to our image URL + if logo is set to True, then the logo_url is used instead + """ - if not self.image_url_mask: + + url_mask = self.image_url_logo if logo else self.image_url_mask + if not url_mask: # No image to return return None @@ -105,7 +146,7 @@ class AppriseAsset(object): re.IGNORECASE, ) - return re_table.sub(lambda x: re_map[x.group()], self.image_url_mask) + return re_table.sub(lambda x: re_map[x.group()], url_mask) def image_path(self, notify_type, image_size, must_exist=True): """ @@ -154,3 +195,28 @@ class AppriseAsset(object): return None return None + + @staticmethod + def hex_to_rgb(value): + """ + Takes a hex string (such as #00ff00) and returns a tuple in the form + of (red, green, blue) + + eg: #00ff00 becomes : (0, 65535, 0) + + """ + value = value.lstrip('#') + lv = len(value) + return tuple(int(value[i:i + lv // 3], 16) + for i in range(0, lv, lv // 3)) + + @staticmethod + def hex_to_int(value): + """ + Takes a hex string (such as #00ff00) and returns its integer + equivalent + + eg: #00000f becomes : 15 + + """ + return int(value.lstrip('#'), 16) diff --git a/apprise/assets/themes/default/apprise-logo.png b/apprise/assets/themes/default/apprise-logo.png new file mode 100644 index 00000000..44df0c7c Binary files /dev/null and b/apprise/assets/themes/default/apprise-logo.png differ diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index cb62093a..a9ecf20d 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -95,12 +95,6 @@ class NotifyBase(object): # This value can be the same as the defined protocol. secure_protocol = '' - # our Application identifier - app_id = 'Apprise' - - # our Application description - app_desc = 'Apprise Notifications' - # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives # us a safe play range... throttle_attempt = 5.5 @@ -177,7 +171,7 @@ class NotifyBase(object): return - def image_url(self, notify_type): + def image_url(self, notify_type, logo=False): """ Returns Image URL if possible """ @@ -191,6 +185,7 @@ class NotifyBase(object): return self.asset.image_url( notify_type=notify_type, image_size=self.image_size, + logo=logo, ) def image_path(self, notify_type): @@ -223,6 +218,30 @@ class NotifyBase(object): image_size=self.image_size, ) + def color(self, notify_type, color_type=None): + """ + Returns the html color (hex code) associated with the notify_type + """ + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.color( + notify_type=notify_type, + color_type=color_type, + ) + + @property + def app_id(self): + return self.asset.app_id + + @property + def app_desc(self): + return self.asset.app_desc + + @property + def app_url(self): + return self.asset.app_url + @staticmethod def escape_html(html, convert_new_lines=False): """ diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py new file mode 100644 index 00000000..6022a920 --- /dev/null +++ b/apprise/plugins/NotifyDiscord.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# +# Discord Notify Wrapper +# +# Copyright (C) 2018 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# For this to work correctly you need to create a webhook. To do this just +# click on the little gear icon next to the channel you're part of. From +# here you'll be able to access the Webhooks menu and create a new one. +# +# When you've completed, you'll get a URL that looks a little like this: +# https://discordapp.com/api/webhooks/417429632418316298/\ +# JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js +# +# Simplified, it looks like this: +# https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN +# +# This plugin will simply work using the url of: +# discord://WEBHOOK_ID/WEBHOOK_TOKEN +# +# API Documentation on Webhooks: +# - https://discordapp.com/developers/docs/resources/webhook +# +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyImageSize +from ..utils import parse_bool + +# Image Support (256x256) +DISCORD_IMAGE_XY = NotifyImageSize.XY_256 + + +class NotifyDiscord(NotifyBase): + """ + A wrapper to Discord Notifications + + """ + + # The default secure protocol + secure_protocol = 'discord' + + # Discord Webhook + notify_url = 'https://discordapp.com/api/webhooks' + + def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, + footer=False, thumbnail=True, **kwargs): + """ + Initialize Discord Object + + """ + super(NotifyDiscord, self).__init__( + title_maxlen=250, body_maxlen=2000, + image_size=DISCORD_IMAGE_XY, **kwargs) + + if not webhook_id: + raise TypeError( + 'An invalid Client ID was specified.' + ) + + if not webhook_token: + raise TypeError( + 'An invalid Webhook Token was specified.' + ) + + # Store our data + self.webhook_id = webhook_id + self.webhook_token = webhook_token + + # Text To Speech + self.tts = tts + + # Over-ride Avatar Icon + self.avatar = avatar + + # Place a footer icon + self.footer = footer + + # Place a thumbnail image inline with the message body + self.thumbnail = thumbnail + + return + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform Discord Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'multipart/form-data', + } + + # Prepare JSON Object + payload = { + # Text-To-Speech + 'tts': self.tts, + + # If Text-To-Speech is set to True, then we do not want to wait + # for the whole message before continuing. Otherwise, we wait + 'wait': self.tts is False, + + # Our color associated with our notification + 'color': self.color(notify_type, int), + + 'embeds': [{ + 'provider': { + 'name': self.app_id, + 'url': self.app_url, + }, + 'title': title, + 'type': 'rich', + 'description': body, + }] + } + + if self.footer: + logo_url = self.image_url(notify_type, logo=True) + payload['embeds'][0]['footer'] = { + 'text': self.app_desc, + } + if logo_url: + payload['embeds'][0]['footer']['icon_url'] = logo_url + + image_url = self.image_url(notify_type) + if image_url: + if self.thumbnail: + payload['embeds'][0]['thumbnail'] = { + 'url': image_url, + 'height': 256, + 'width': 256, + } + + if self.avatar: + payload['avatar_url'] = image_url + + if self.user: + # Optionally override the default username of the webhook + payload['username'] = self.user + + # Construct Notify URL + notify_url = '{0}/{1}/{2}'.format( + self.notify_url, + self.webhook_id, + self.webhook_token, + ) + + self.logger.debug('Discord POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Discord Payload: %s' % str(payload)) + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + try: + self.logger.warning( + 'Failed to send Discord notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send Discord notification ' + '(error=%s).' % r.status_code) + + self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + return False + + else: + self.logger.info('Sent Discord notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Discord ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + Syntax: + discord://webhook_id/webhook_token + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Store our webhook ID + webhook_id = results['host'] + + # Now fetch our tokens + try: + webhook_token = [x for x in filter(bool, NotifyBase.split_path( + results['fullpath']))][0] + + except (ValueError, AttributeError, IndexError): + # Force some bad values that will get caught + # in parsing later + webhook_token = None + + results['webhook_id'] = webhook_id + results['webhook_token'] = webhook_token + + # Text To Speech + results['tts'] = parse_bool(results['qsd'].get('tts', False)) + + # Use Footer + results['footer'] = parse_bool(results['qsd'].get('footer', False)) + + # Update Avatar Icon + results['avatar'] = parse_bool(results['qsd'].get('avatar', True)) + + # Use Thumbnail + results['thumbnail'] = \ + parse_bool(results['qsd'].get('thumbnail', True)) + + return results diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index f8781232..802ea734 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -220,7 +220,7 @@ class NotifySlack(NotifyBase): 'attachments': [{ 'title': title, 'text': body, - 'color': self.asset.html_color(notify_type), + 'color': self.color(notify_type), # Time 'ts': time(), 'footer': self.app_id, diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index b0213a56..19699182 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -21,6 +21,7 @@ from . import NotifyEmail as NotifyEmailBase from .NotifyBoxcar import NotifyBoxcar +from .NotifyDiscord import NotifyDiscord from .NotifyEmail import NotifyEmail from .NotifyFaast import NotifyFaast from .NotifyGrowl.NotifyGrowl import NotifyGrowl @@ -55,7 +56,7 @@ __all__ = [ 'NotifyMyAndroid', 'NotifyProwl', 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushover', 'NotifyRocketChat', 'NotifyToasty', 'NotifyTwitter', 'NotifyXBMC', 'NotifyXML', 'NotifySlack', 'NotifyJoin', 'NotifyTelegram', - 'NotifyMatterMost', 'NotifyPushjet', + 'NotifyMatterMost', 'NotifyPushjet', 'NotifyDiscord', # Reference 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', diff --git a/test/test_api.py b/test/test_api.py index 0238b654..bbaa6a53 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -298,8 +298,30 @@ def test_apprise_asset(tmpdir): a.default_html_color = '#abcabc' a.html_notify_map[NotifyType.INFO] = '#aaaaaa' - assert(a.html_color('invalid') == '#abcabc') - assert(a.html_color(NotifyType.INFO) == '#aaaaaa') + assert(a.color('invalid', tuple) == (171, 202, 188)) + assert(a.color(NotifyType.INFO, tuple) == (170, 170, 170)) + + assert(a.color('invalid', int) == 11258556) + assert(a.color(NotifyType.INFO, int) == 11184810) + + assert(a.color('invalid', None) == '#abcabc') + assert(a.color(NotifyType.INFO, None) == '#aaaaaa') + # None is the default + assert(a.color(NotifyType.INFO) == '#aaaaaa') + + # Invalid Type + try: + a.color(NotifyType.INFO, dict) + # We should not get here (exception should be thrown) + assert(False) + + except ValueError: + # The exception we expect since dict is not supported + assert(True) + + except: + # Any other exception is not good + assert(False) assert(a.image_url(NotifyType.INFO, NotifyImageSize.XY_256) == 'http://localhost/dark/info-256x256.png') diff --git a/test/test_notify_base.py b/test/test_notify_base.py index f4af0a19..8d0bb709 100644 --- a/test/test_notify_base.py +++ b/test/test_notify_base.py @@ -20,6 +20,7 @@ from apprise.plugins.NotifyBase import NotifyBase from apprise import NotifyType from apprise import NotifyImageSize from timeit import default_timer +from apprise.utils import compat_is_basestring def test_notify_base(): @@ -75,6 +76,15 @@ def test_notify_base(): assert nb.image_path(notify_type=NotifyType.INFO) is None assert nb.image_raw(notify_type=NotifyType.INFO) is None + # Color handling + assert nb.color(notify_type='invalid') is None + assert compat_is_basestring( + nb.color(notify_type=NotifyType.INFO, color_type=None)) + assert isinstance( + nb.color(notify_type=NotifyType.INFO, color_type=int), int) + assert isinstance( + nb.color(notify_type=NotifyType.INFO, color_type=tuple), tuple) + # Create an object with an ImageSize loaded into it nb = NotifyBase(image_size=NotifyImageSize.XY_256) diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index d11ee2a5..2ed184ca 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -87,6 +87,65 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyDiscord + ################################## + ('discord://', { + 'instance': None, + }), + # No webhook_token specified + ('discord://%s' % ('i' * 24), { + 'instance': TypeError, + }), + # Provide both an webhook id and a webhook token + ('discord://%s/%s' % ('i' * 24, 't' * 64), { + 'instance': plugins.NotifyDiscord, + 'requests_response_code': requests.codes.no_content, + }), + # Provide a temporary username + ('discord://l2g@%s/%s' % ('i' * 24, 't' * 64), { + 'instance': plugins.NotifyDiscord, + 'requests_response_code': requests.codes.no_content, + }), + # Enable other options + ('discord://%s/%s?footer=Yes&thumbnail=Yes' % ('i' * 24, 't' * 64), { + 'instance': plugins.NotifyDiscord, + 'requests_response_code': requests.codes.no_content, + }), + ('discord://%s/%s?avatar=No&footer=No' % ('i' * 24, 't' * 64), { + 'instance': plugins.NotifyDiscord, + 'requests_response_code': requests.codes.no_content, + }), + # Test without image set + ('discord://%s/%s' % ('i' * 24, 't' * 64), { + 'instance': plugins.NotifyDiscord, + 'requests_response_code': requests.codes.no_content, + # don't include an image by default + 'include_image': False, + }), + # An invalid url + ('discord://:@/', { + 'instance': None, + }), + ('discord://%s/%s/' % ('a' * 24, 'b' * 64), { + 'instance': plugins.NotifyDiscord, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('discord://%s/%s/' % ('a' * 24, 'b' * 64), { + 'instance': plugins.NotifyDiscord, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('discord://%s/%s/' % ('a' * 24, 'b' * 64), { + 'instance': plugins.NotifyDiscord, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyFaast ################################## @@ -1384,6 +1443,51 @@ def test_notify_boxcar_plugin(mock_post, mock_get): p.notify(body=None, title=None, notify_type=NotifyType.INFO) is True +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_notify_discord_plugin(mock_post, mock_get): + """ + API: NotifyDiscord() Extra Checks + + """ + + # Initialize some generic (but valid) tokens + webhook_id = 'A' * 24 + webhook_token = 'B' * 64 + + # Prepare Mock + mock_get.return_value = requests.Request() + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + mock_get.return_value.status_code = requests.codes.ok + + # Empty Channel list + try: + plugins.NotifyDiscord(webhook_id=None, webhook_token=webhook_token) + assert(False) + + except TypeError: + # we'll thrown because no webhook_id was specified + assert(True) + + obj = plugins.NotifyDiscord( + webhook_id=webhook_id, + webhook_token=webhook_token, + footer=True, thumbnail=False) + + # Disable throttling to speed up unit tests + obj.throttle_attempt = 0 + + # This call includes an image with it's payload: + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + + # Toggle our logo availability + obj.asset.image_url_logo = None + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + + @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_join_plugin(mock_post, mock_get):