From afddd9e89736699184a66ec9f2bc2c020d3c7e32 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 8 Mar 2018 20:20:51 -0500 Subject: [PATCH] Added IFTTT support; fixes #7 --- README.md | 1 + apprise/plugins/NotifyIFTTT.py | 218 +++++++++++++++++++++++++++++++++ apprise/plugins/__init__.py | 3 +- test/test_rest_plugins.py | 106 ++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 apprise/plugins/NotifyIFTTT.py diff --git a/README.md b/README.md index 8efeb3ac..71c265ee 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The table below identifies the services this tool supports and some example serv | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [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 +| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/EventToTrigger
ifttt://webhooksID/EventToTrigger/Value1/Value2/Value3
ifttt://webhooksID/EventToTrigger/?Value3=NewEntry&Value2=AnotherValue | [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/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py new file mode 100644 index 00000000..02c941dc --- /dev/null +++ b/apprise/plugins/NotifyIFTTT.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# +# IFTTT (If-This-Then-That) +# +# Copyright (C) 2017-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 plugin to work, you need to add the Maker applet to your profile +# Simply visit https://ifttt.com/search and search for 'Webhooks' +# Or if you're signed in, click here: https://ifttt.com/maker_webhooks +# and click 'Connect' +# +# You'll want to visit the settings of this Applet and pay attention to the +# URL. For example, it might look like this: +# https://maker.ifttt.com/use/a3nHB7gA9TfBQSqJAHklod +# +# In the above example a3nHB7gA9TfBQSqJAHklod becomes your {apikey} +# You will need this to make this notification work correctly +# +# For each event you create you will assign it a name (this will be known as +# the {event} when building your URL. +import requests + +from json import dumps +from .NotifyBase import NotifyBase +from .NotifyBase import HTTP_ERROR_MAP + + +class NotifyIFTTT(NotifyBase): + """ + A wrapper for IFTTT Notifications + + """ + + # Even though you'll add 'Ingredients' as {{ Value1 }} to your Applets, + # you must use their lowercase value in the HTTP POST. + ifttt_default_key_prefix = 'value' + + # The default IFTTT Key to use when mapping the title text to the IFTTT + # event. The idea here is if someone wants to over-ride the default and + # change it to another Ingredient Name (in 2018, you were limited to have + # value1, value2, and value3). + ifttt_default_title_key = 'value1' + + # The default IFTTT Key to use when mapping the body text to the IFTTT + # event. The idea here is if someone wants to over-ride the default and + # change it to another Ingredient Name (in 2018, you were limited to have + # value1, value2, and value3). + ifttt_default_body_key = 'value2' + + # The default IFTTT Key to use when mapping the body text to the IFTTT + # event. The idea here is if someone wants to over-ride the default and + # change it to another Ingredient Name (in 2018, you were limited to have + # value1, value2, and value3). + ifttt_default_type_key = 'value3' + + # The default protocol + protocol = 'ifttt' + + # IFTTT uses the http protocol with JSON requests + notify_url = 'https://maker.ifttt.com/trigger/{event}/with/key/{apikey}' + + def __init__(self, apikey, event, event_args=None, **kwargs): + """ + Initialize IFTTT Object + + """ + super(NotifyIFTTT, self).__init__( + title_maxlen=250, body_maxlen=32768, **kwargs) + + if not apikey: + raise TypeError('You must specify the Webhooks apikey.') + + if not event: + raise TypeError('You must specify the Event you wish to trigger.') + + # Store our APIKey + self.apikey = apikey + + # Store our Event we wish to trigger + self.event = event + + if isinstance(event_args, dict): + # Make a copy of the arguments so that they can't change + # outside of this plugin + self.event_args = event_args.copy() + + else: + # Force a dictionary + self.event_args = dict() + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform IFTTT Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # prepare JSON Object + payload = { + self.ifttt_default_title_key: title, + self.ifttt_default_body_key: body, + self.ifttt_default_type_key: notify_type, + } + + # Update our payload using any other event_args specified + payload.update(self.event_args) + + # Eliminate empty fields; users wishing to cancel the use of the + # self.ifttt_default_ entries can preset these keys to being + # empty so that they get caught here and removed. + payload = {x: y for x, y in payload.items() if y} + + # URL to transmit content via + url = self.notify_url.format( + apikey=self.apikey, + event=self.event, + ) + + self.logger.debug('IFTTT POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('IFTTT Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + self.logger.debug( + u"IFTTT HTTP response status: %r" % r.status_code) + self.logger.debug( + u"IFTTT HTTP response headers: %r" % r.headers) + self.logger.debug( + u"IFTTT HTTP response body: %r" % r.content) + + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send IFTTT:%s ' + 'notification: %s (error=%s).' % ( + self.event, + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send IFTTT:%s ' + 'notification (error=%s).' % ( + self.event, + r.status_code)) + + # self.logger.debug('Response Details: %s' % r.content) + return False + + else: + self.logger.info( + 'Sent IFTTT notification to Event %s.' % self.event) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending IFTTT:%s ' % ( + self.event) + '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. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Our Event + results['event'] = results['host'] + + # Our API Key + results['apikey'] = results['user'] + + # Store ValueX entries based on each entry past the host + results['event_args'] = { + '{0}{1}'.format(NotifyIFTTT.ifttt_default_key_prefix, n + 1): + NotifyBase.unquote(x) + for n, x in enumerate( + NotifyBase.split_path(results['fullpath'])) if x} + + # Allow users to set key=val parameters to specify more types + # of payload options + results['event_args'].update( + {k: NotifyBase.unquote(v) + for k, v in results['qsd'].items()}) + + return results diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index e6f4f62f..873dcbf8 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -26,6 +26,7 @@ from .NotifyEmail import NotifyEmail from .NotifyEmby import NotifyEmby from .NotifyFaast import NotifyFaast from .NotifyGrowl.NotifyGrowl import NotifyGrowl +from .NotifyIFTTT import NotifyIFTTT from .NotifyJoin import NotifyJoin from .NotifyJSON import NotifyJSON from .NotifyMatterMost import NotifyMatterMost @@ -56,7 +57,7 @@ from ..common import NOTIFY_TYPES __all__ = [ # Notification Services 'NotifyBoxcar', 'NotifyEmail', 'NotifyEmby', 'NotifyDiscord', - 'NotifyFaast', 'NotifyGrowl', 'NotifyJoin', 'NotifyJSON', + 'NotifyFaast', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin', 'NotifyJSON', 'NotifyMatterMost', 'NotifyMyAndroid', 'NotifyProwl', 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifyStride', 'NotifyToasty', 'NotifyTwitter', diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 4e1264c7..e45d2c7a 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -291,6 +291,72 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyIFTTT - If This Than That + ################################## + ('ifttt://', { + 'instance': None, + }), + # No User + ('ifttt://EventID/', { + 'instance': TypeError, + }), + # Value1 gets assigned Entry1 + # Title = + # Body = + ('ifttt://WebHookID@EventID/Entry1/', { + 'instance': plugins.NotifyIFTTT, + }), + # Value1, Value2, and Value2, the below assigns: + # Value1 = Entry1 + # Value2 = AnotherEntry + # Value3 = ThirdValue + # Title = + # Body = + ('ifttt://WebHookID@EventID/Entry1/AnotherEntry/ThirdValue', { + 'instance': plugins.NotifyIFTTT, + }), + # Mix and match content, the below assigns: + # Value1 = FirstValue + # AnotherKey = Hello + # Value5 = test + # Title = + # Body = + ('ifttt://WebHookID@EventID/FirstValue/?AnotherKey=Hello&Value5=test', { + 'instance': plugins.NotifyIFTTT, + }), + # This would assign: + # Value1 = FirstValue + # Title = - disable the one passed by the notify call + # Body = - disable the one passed by the notify call + # The idea here is maybe you just want to use the apprise IFTTTT hook + # to trigger something and not nessisarily pass text along to it + ('ifttt://WebHookID@EventID/FirstValue/?Title=&Body=', { + 'instance': plugins.NotifyIFTTT, + }), + ('ifttt://:@/', { + 'instance': None, + }), + # Test website connection failures + ('ifttt://WebHookID@EventID', { + 'instance': plugins.NotifyIFTTT, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('ifttt://WebHookID@EventID', { + 'instance': plugins.NotifyIFTTT, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('ifttt://WebHookID@EventID', { + 'instance': plugins.NotifyIFTTT, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyJoin ################################## @@ -1960,6 +2026,46 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout, assert obj.notify('title', 'body', 'info') is True +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_notify_ifttt_plugin(mock_post, mock_get): + """ + API: NotifyIFTTT() Extra Checks + + """ + + # Initialize some generic (but valid) tokens + apikey = 'webhookid' + event = 'event' + + # 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 + mock_get.return_value.content = '{}' + mock_post.return_value.content = '{}' + + try: + obj = plugins.NotifyIFTTT(apikey=apikey, event=None, event_args=None) + # No token specified + assert(False) + + except TypeError: + # Exception should be thrown about the fact no token was specified + assert(True) + + obj = plugins.NotifyIFTTT(apikey=apikey, event=event, event_args=None) + assert(isinstance(obj, plugins.NotifyIFTTT)) + assert(len(obj.event_args) == 0) + + # Disable throttling to speed up unit tests + obj.throttle_attempt = 0 + + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + + def test_notify_stride_plugin(): """ API: NotifyStride() Extra Checks