diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index 33ee7bf4..fa55c60c 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -19,3 +19,6 @@ The contributors have been listed in alphabetical order: * Wim de With * Dec 2018 - Added Matrix Support + +* Hitesh Sondhi + * Mar 2019 - Added Flock Support diff --git a/README.md b/README.md index 02dcad00..52852973 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The table below identifies the services this tool supports and some example serv | [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 | [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 | [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 @@ -53,6 +54,7 @@ The table below identifies the services this tool supports and some example serv | [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/Channel1/Channel1/RoomID
rocket://user:password@hostname/Channel | [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN +| [Flock](https://github.com/caronc/apprise/wiki/Notify_flock) | flock:// | (TCP) 443 | flock://Token
flock://Token/?contenttype=text
flock://Token/?contenttype=flockml | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret | [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 diff --git a/apprise/plugins/NotifyFlock.py b/apprise/plugins/NotifyFlock.py new file mode 100644 index 00000000..41b38a5c --- /dev/null +++ b/apprise/plugins/NotifyFlock.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 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. + +# To use this plugin, you need to first access https://dev.flock.com/webhooks +# Specifically https://dev.flock.com/webhooks/incoming +# to create a new incoming webhook for your account. You'll need to +# follow the wizard to pre-determine the channel(s) you want your +# message to broadcast to, and when you're complete, you will +# recieve a URL that looks something like this: +# https://api.flock.com/hooks/sendMessage/134b8gh0-eba0-4fa9-ab9c-257ced0e8221 +# ^ +# | +# This is important <----------------------------------------^ +# +# +import re +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..common import NotifyImageSize +from ..utils import parse_list + + +# Extend HTTP Error Messages +FLOCK_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + +# Default User +FLOCK_DEFAULT_USER = 'apprise' + +# Used to detect a channel/user +IS_CHANNEL_RE = re.compile(r'^(#|g:)(?P[A-Z0-9_]{12})$', re.I) +IS_USER_RE = re.compile(r'^(@|u:)?(?P[A-Z0-9_]{12})$', re.I) + +# Token required as part of the API request +# /134b8gh0-eba0-4fa9-ab9c-257ced0e8221 +IS_API_TOKEN = re.compile(r'^[a-z0-9-]{24}$', re.I) + + +class NotifyFlock(NotifyBase): + """ + A wrapper for Flock Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Flock' + + # The services URL + service_url = 'https://flock.com/' + + # The default secure protocol + secure_protocol = 'flock' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_flock' + + # Flock uses the http protocol with JSON requests + notify_url = 'https://api.flock.com/hooks/sendMessage' + + # API Wrapper + notify_api = 'https://api.flock.co/v1/chat.sendMessage' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + def __init__(self, token, targets=None, **kwargs): + """ + Initialize Flock Object + """ + super(NotifyFlock, self).__init__(**kwargs) + + # Build ourselves a target list + self.targets = list() + + # Initialize our token object + self.token = token.strip() + + if not IS_API_TOKEN.match(self.token): + msg = 'The Flock API Token specified ({}) is invalid.'.format( + self.token) + self.logger.warning(msg) + raise TypeError(msg) + + # Track any issues + has_error = False + + # Tidy our targets + targets = parse_list(targets) + + for target in targets: + result = IS_USER_RE.match(target) + if result: + self.targets.append('u:' + result.group('id')) + continue + + result = IS_CHANNEL_RE.match(target) + if result: + self.targets.append('g:' + result.group('id')) + continue + + has_error = True + self.logger.warning( + 'Ignoring invalid target ({}) specified.'.format(target)) + + if has_error and len(self.targets) == 0: + # We have a bot token and no target(s) to message + msg = 'No targets found with specified Flock Bot Token.' + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Flock Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # error tracking (used for function return) + has_error = False + + if self.notify_format == NotifyFormat.HTML: + body = '{}'.format(body) + + else: + title = NotifyBase.escape_html(title, whitespace=False) + body = NotifyBase.escape_html(body, whitespace=False) + + body = '{}{}'.format( + '' if not title else '{}
'.format(title), body) + + payload = { + 'token': self.token, + 'flockml': body, + 'sendAs': { + 'name': FLOCK_DEFAULT_USER if not self.user else self.user, + 'profileImage': self.image_url(notify_type), + } + } + + if len(self.targets): + # Create a copy of our targets + targets = list(self.targets) + + while len(targets) > 0: + # Get our first item + target = targets.pop(0) + + # Copy and update our payload + _payload = payload.copy() + _payload['to'] = target + + if not self._post(self.notify_api, headers, _payload): + has_error = True + + else: + # Webhook + url = '{}/{}'.format(self.notify_url, self.token) + if not self._post(url, headers, payload): + has_error = True + + return not has_error + + def _post(self, url, headers, payload): + """ + A wrapper to the requests object + """ + + # error tracking (used for function return) + has_error = False + + self.logger.debug('Flock POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate)) + self.logger.debug('Flock Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, FLOCK_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Flock notification : ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + + else: + self.logger.info('Sent Flock notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Flock notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + + return not has_error + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + } + + return '{schema}://{token}/{targets}?{args}'\ + .format( + schema=self.secure_protocol, + token=self.quote(self.token, safe=''), + targets='/'.join( + [self.quote(target, safe='') for target in self.targets]), + args=self.urlencode(args), + ) + + @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 + + # Apply our settings now + + results['targets'] = [x for x in filter( + bool, NotifyBase.split_path(results['fullpath']))] + + # The first token is stored in the hostname + results['token'] = results['host'] + + return results diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index d5b22a77..e44c4a48 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -269,6 +269,94 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyFlock + ################################## + # No token specified + ('flock://', { + 'instance': None, + }), + # Provide a token + ('flock://%s' % ('t' * 24), { + 'instance': plugins.NotifyFlock, + }), + # Provide markdown format + ('flock://%s?format=markdown' % ('i' * 24), { + 'instance': plugins.NotifyFlock, + }), + # Provide text format + ('flock://%s?format=text' % ('i' * 24), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # Provide markdown format + ('flock://%s/u:%s?format=markdown' % ('i' * 24, 'u' * 12), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # Provide text format + ('flock://%s/u:%s?format=html' % ('i' * 24, 'u' * 12), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # u: is optional + ('flock://%s/%s?format=text' % ('i' * 24, 'u' * 12), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # Multi-entries + ('flock://%s/g:%s/u:%s?format=text' % ('i' * 24, 'g' * 12, 'u' * 12), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # Multi-entries using @ for user and # for channel + ('flock://%s/#%s/@%s?format=text' % ('i' * 24, 'g' * 12, 'u' * 12), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # has bad entry + ('flock://%s/g:%s/u:%s?format=text' % ('i' * 24, 'g' * 12, 'u' * 10), { + 'instance': plugins.NotifyFlock, + }), + # Bot API presumed if one or more targets are specified + # has all bad entries + ('flock://%s/g:%s/u:%s?format=text' % ('i' * 24, 'g' * 14, 'u' * 10), { + 'instance': TypeError, + }), + # Provide invalid token + ('flock://%s?format=text' % ('i' * 10), { + 'instance': TypeError, + }), + # An invalid url + ('flock://:@/', { + 'instance': None, + }), + # Error Testing + ('flock://%s/g:%s/u:%s?format=text' % ('i' * 24, 'g' * 12, 'u' * 10), { + 'instance': plugins.NotifyFlock, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('flock://%s/' % ('t' * 24), { + 'instance': plugins.NotifyFlock, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('flock://%s/' % ('t' * 24), { + 'instance': plugins.NotifyFlock, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('flock://%s/' % ('t' * 24), { + 'instance': plugins.NotifyFlock, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyGotify ##################################