From 2609680792edaed0fe98b6bed12c7d12f5d5599b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 18 Jul 2019 22:41:10 -0400 Subject: [PATCH] Added Twist Support (#137) --- README.md | 1 + apprise/plugins/NotifyTwist.py | 805 +++++++++++++++++++++++++++ apprise/utils.py | 4 +- packaging/redhat/python-apprise.spec | 2 +- setup.py | 2 +- test/test_rest_plugins.py | 53 ++ test/test_twist_plugin.py | 485 ++++++++++++++++ 7 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 apprise/plugins/NotifyTwist.py create mode 100644 test/test_twist_plugin.py diff --git a/README.md b/README.md index 3aae6122..ea28b6b3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ The table below identifies the services this tool supports and some example serv | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [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) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet +| [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:// diff --git a/apprise/plugins/NotifyTwist.py b/apprise/plugins/NotifyTwist.py new file mode 100644 index 00000000..1c15ce94 --- /dev/null +++ b/apprise/plugins/NotifyTwist.py @@ -0,0 +1,805 @@ +# -*- 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. +# +# All of the documentation needed to work with the Twist API can be found +# here: https://developer.twist.com/v3/ + +import re +import requests +from json import loads +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import GET_EMAIL_RE +from ..AppriseLocale import gettext_lazy as _ + + +# A workspace can also be interpreted as a team name too! +IS_CHANNEL = re.compile( + r'^#?(?P((?P[A-Za-z0-9_-]+):)?' + r'(?P[^\s]{1,64}))$') + +IS_CHANNEL_ID = re.compile( + r'^(?P((?P[0-9]+):)?(?P[0-9]+))$') + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyTwist(NotifyBase): + """ + A wrapper for Notify Twist Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Twist' + + # The services URL + service_url = 'https://twist.com' + + # The default secure protocol + secure_protocol = 'twist' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twist' + + # The maximum size of the message + body_maxlen = 1000 + + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + # The default Notification URL to use + api_url = 'https://api.twist.com/api/v3/' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.2 + + # The default channel to notify if no targets are specified + default_notification_channel = 'general' + + # Define object templates + templates = ( + '{schema}://{password}:{email}', + '{schema}://{password}:{email}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'email': { + 'name': _('Email'), + 'type': 'string', + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'target_channel_id': { + 'name': _('Target Channel ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, email=None, targets=None, **kwargs): + """ + Initialize Notify Twist Object + """ + super(NotifyTwist, self).__init__(**kwargs) + + # Initialize channels list + self.channels = set() + + # Initialize Channel ID which are stored as: + # : + self.channel_ids = set() + + # Initialize our Email Object + self.email = email if email else '{}@{}'.format( + self.user, + self.host, + ) + + # The token is None if we're not logged in and False if we + # failed to log in. Otherwise it is set to the actual token + self.token = None + + # Our default workspace (associated with our token) + self.default_workspace = None + + # A set of all of the available workspaces + self._cached_workspaces = set() + + # A mapping of channel names, the layout is as follows: + # { + # : { + # : , + # : , + # ... + # }, + # : { + # : , + # : , + # ... + # }, + # } + self._cached_channels = dict() + + try: + result = GET_EMAIL_RE.match(self.email) + if not result: + # let outer exception handle this + raise TypeError + + if email: + # Force user/host to be that of the defined email for + # consistency. This is very important for those initializing + # this object with the the email object would could potentially + # cause inconsistency to contents in the NotifyBase() object + self.user = result.group('fulluser') + self.host = result.group('domain') + + except (TypeError, AttributeError): + msg = 'The Twist Auth email specified ({}) is invalid.'\ + .format(self.email) + self.logger.warning(msg) + raise TypeError(msg) + + if not self.password: + msg = 'No Twist password was specified with account: {}'\ + .format(self.email) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate recipients and drop bad ones: + for recipient in parse_list(targets): + result = IS_CHANNEL_ID.match(recipient) + if result: + # store valid channel id + self.channel_ids.add(result.group('name')) + continue + + result = IS_CHANNEL.match(recipient) + if result: + # store valid device + self.channels.add(result.group('name').lower()) + continue + + self.logger.warning( + 'Dropped invalid channel/id ' + '({}) specified.'.format(recipient), + ) + + if len(self.channels) + len(self.channel_ids) == 0: + # Notify our default channel + self.channels.add(self.default_notification_channel) + self.logger.warning( + 'Added default notification channel {}'.format( + self.default_notification_channel)) + return + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + return '{schema}://{password}:{user}@{host}/{targets}/?{args}'.format( + schema=self.secure_protocol, + password=self.quote(self.password, safe=''), + user=self.quote(self.user, safe=''), + host=self.host, + targets='/'.join( + [NotifyTwist.quote(x, safe='') for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Channel IDs + self.channel_ids, + )]), + args=NotifyTwist.urlencode(args), + ) + + def login(self): + """ + A simple wrapper to authenticate with the Twist Server + """ + + # Prepare our payload + payload = { + 'email': self.email, + 'password': self.password, + } + + # Reset our default workspace + self.default_workspace = None + + # Reset our cached objects + self._cached_workspaces = set() + self._cached_channels = dict() + + # Send Login Information + postokay, response = self._fetch( + 'users/login', + payload=payload, + # We set this boolean so internal recursion doesn't take place. + login=True, + ) + + if not postokay or not response: + # Setting this variable to False as a way of letting us know + # we failed to authenticate on our last attempt + self.token = False + return False + + # Our response object looks like this (content has been altered for + # presentation purposes): + # { + # "contact_info": null, + # "profession": null, + # "timezone": "UTC", + # "avatar_id": null, + # "id": 123456, + # "first_name": "Jordan", + # "comet_channel": + # "124371-34be423219130343030d4ec0a3dabbbbbe565eee", + # "restricted": false, + # "default_workspace": 92020, + # "snooze_dnd_end": null, + # "email": "user@example.com", + # "comet_server": "https://comet.twist.com", + # "snooze_until": null, + # "lang": "en", + # "feature_flags": [], + # "short_name": "Jordan P.", + # "away_mode": null, + # "time_format": "12", + # "client_id": "cb01f37e-a5b2-13e9-ba2a-023a33d10dc0", + # "removed": false, + # "emails": [ + # { + # "connected": [], + # "email": "user@example.com", + # "primary": true + # } + # ], + # "scheduled_banners": [ + # "threads_3", + # "threads_1", + # "notification_permissions", + # "search_1", + # "messages_1", + # "team_1", + # "inbox_2", + # "inbox_1" + # ], + # "snooze_dnd_start": null, + # "name": "Jordan Peterson", + # "off_days": [], + # "bot": false, + # "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", + # "snoozed": false, + # "setup_pending": false, + # "date_format": "MM/DD/YYYY" + # } + + # Store our default workspace + self.default_workspace = response.get('default_workspace') + + # Acquire our token + self.token = response.get('token') + + self.logger.info('Authenticated to Twist as {}'.format(self.email)) + return True + + def logout(self): + """ + A simple wrapper to log out of the server + """ + + if not self.token: + # Nothing more to do + return True + + # Send Logout Message + postokay, response = self._fetch('users/logout') + + # reset our token + self.token = None + + # There is no need to handling failed log out attempts at this time + return True + + def get_workspaces(self): + """ + Returns all workspaces associated with this user account as a set + + This returned object is either an empty dictionary or one that + looks like this: + { + 'workspace': , + 'workspace': , + 'workspace': , + } + + All workspaces are made lowercase for comparison purposes + """ + if not self.token and not self.login(): + # Nothing more to do + return dict() + + postokay, response = self._fetch('workspaces/get') + if not postokay or not response: + # We failed to retrieve + return dict() + + # The response object looks like so: + # [ + # { + # "created_ts": 1563044447, + # "name": "apprise", + # "creator": 123571, + # "color": 1, + # "default_channel": 13245, + # "plan": "free", + # "default_conversation": 63022, + # "id": 12345 + # } + # ] + + # Knowing our response, we can iterate over each object and cache our + # object + result = {} + for entry in response: + result[entry.get('name', '').lower()] = entry.get('id', '') + + return result + + def get_channels(self, wid): + """ + Simply returns the channel objects associated with the specified + workspace id. + + This returned object is either an empty dictionary or one that + looks like this: + { + 'channel1': , + 'channel2': , + 'channel3': , + } + + All channels are made lowercase for comparison purposes + """ + if not self.token and not self.login(): + # Nothing more to do + return {} + + payload = {'workspace_id': wid} + postokay, response = self._fetch( + 'channels/get', payload=payload) + + if not postokay or not isinstance(response, list): + # We failed to retrieve + return {} + + # Response looks like this: + # [ + # { + # "id": 123, + # "name": "General" + # "workspace_id": 12345, + # "color": 1, + # "description": "", + # "archived": false, + # "public": true, + # "user_ids": [ + # 8754 + # ], + # "created_ts": 1563044447, + # "creator": 123571, + # } + # ] + # + # Knowing our response, we can iterate over each object and cache our + # object + result = {} + for entry in response: + result[entry.get('name', '').lower()] = entry.get('id', '') + + return result + + def _channel_migration(self): + """ + A simple wrapper to get all of the current workspaces including + the default one. This plays a role in what channel(s) get notified + and where. + + A cache lookup has overhead, and is only required to be preformed + if the user specified channels by their string value + """ + + if not self.token and not self.login(): + # Nothing more to do + return False + + if not len(self.channels): + # Nothing to do; take an early exit + return True + + if self.default_workspace \ + and self.default_workspace not in self._cached_channels: + # Get our default workspace entries + self._cached_channels[self.default_workspace] = \ + self.get_channels(self.default_workspace) + + # initialize our error tracking + has_error = False + + while len(self.channels): + # Pop our channel off of the stack + result = IS_CHANNEL.match(self.channels.pop()) + + # Populate our key variables + workspace = result.group('workspace') + channel = result.group('channel').lower() + + # Acquire our workspace_id if we can + if workspace: + # We always work with the workspace in it's lowercase form + workspace = workspace.lower() + + # A workspace was defined + if not len(self._cached_workspaces): + # cache our workspaces; this only needs to be done once + self._cached_workspaces = self.get_workspaces() + + if workspace not in self._cached_workspaces: + # not found + self.logger.warning( + 'The Twist User {} is not associated with the ' + 'Team {}'.format(self.email, workspace)) + + # Toggle our return flag + has_error = True + continue + + # Store the workspace id + workspace_id = self._cached_workspaces[workspace] + + else: + # use default workspace + workspace_id = self.default_workspace + + # Check to see if our channel exists in our default workspace + if workspace_id in self._cached_channels \ + and channel in self._cached_channels[workspace_id]: + # Store our channel ID + self.channel_ids.add('{}:{}'.format( + workspace_id, + self._cached_channels[workspace_id][channel], + )) + continue + + # if we reach here, we failed to add our channel + self.logger.warning( + 'The Channel #{} was not found{}.'.format( + channel, + '' if not workspace + else ' with Team {}'.format(workspace), + )) + + # Toggle our return flag + has_error = True + continue + + # There is no need to handling failed log out attempts at this time + return not has_error + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Twist Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.token and not self.login(): + # We failed to authenticate - we're done + return False + + if len(self.channels) > 0: + # Converts channels to their maped IDs if found; this is the only + # way to send notifications to Twist + self._channel_migration() + + if not len(self.channel_ids): + # We have nothing to notify + return False + + # Notify all of our identified channels + ids = list(self.channel_ids) + while len(ids) > 0: + # Retrieve our Channel Object + result = IS_CHANNEL_ID.match(ids.pop()) + + # We need both the workspace/team id and channel id + channel_id = int(result.group('channel')) + + # Prepare our payload + payload = { + 'channel_id': channel_id, + 'title': title, + 'content': body, + } + + postokay, response = self._fetch( + 'threads/add', + payload=payload, + ) + + # only toggle has_error flag if we had an error + if not postokay: + # Mark our failure + has_error = True + continue + + # If we reach here, we were successful + self.logger.info( + 'Sent Twist notification to {}.'.format( + result.group('name'))) + + return not has_error + + def _fetch(self, url, payload=None, method='POST', login=False): + """ + Wrapper to Twist API requests object + """ + + # use what was specified, otherwise build headers dynamically + headers = { + 'User-Agent': self.app_id, + } + + headers['Content-Type'] = \ + 'application/x-www-form-urlencoded; charset=utf-8' + + if self.token: + # Set our token + headers['Authorization'] = 'Bearer {}'.format(self.token) + + # Prepare our api url + api_url = '{}{}'.format(self.api_url, url) + + # Some Debug Logging + self.logger.debug('Twist {} URL: {} (cert_verify={})'.format( + method, api_url, self.verify_certificate)) + self.logger.debug('Twist Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made; + self.throttle() + + # Initialize a default value for our content value + content = {} + + # acquire our request mode + fn = requests.post if method == 'POST' else requests.get + try: + r = fn( + api_url, + data=payload, + headers=headers, + verify=self.verify_certificate) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + content = {} + + # handle authentication errors where our token has just simply + # expired. The error response content looks like this: + # { + # "error_code": 200, + # "error_uuid": "af80bd0715434231a649f2258d7fb946", + # "error_extra": {}, + # "error_string": "Invalid token" + # } + # + # Authentication related codes: + # 120 = You are not logged in + # 200 = Invalid Token + # + # Source: https://developer.twist.com/v3/#errors + # + # We attempt to login again and retry the original request + # if we aren't in the process of handling a login already + if r.status_code != requests.codes.ok and login is False \ + and isinstance(content, dict) and \ + content.get('error_code') in (120, 200): + # We failed to authenticate with our token; login one more + # time and retry this original request + if self.login(): + r = fn( + api_url, + data=payload, + headers=headers, + verify=self.verify_certificate) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + content = {} + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyTwist.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Twist {} to {}: ' + '{}error={}.'.format( + method, + api_url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Twist {} to {}: '. + format(method, api_url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + @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 + + if not results.get('user'): + # A username is required + return None + + # Acquire our targets + results['targets'] = NotifyTwist.split_path(results['fullpath']) + + if not results.get('password'): + # Password is required; we will accept the very first entry on the + # path as a password instead + if len(results['targets']) == 0: + # No targets to get our password from + return None + + # We need to requote contents since this variable will get + # unquoted later on in the process. This step appears a bit + # hacky, but it allows us to support the password in this location + # - twist://user@example.com/password + results['password'] = NotifyTwist.quote( + results['targets'].pop(0), safe='') + + else: + # Now we handle our format: + # twist://password:email + # + # since URL logic expects + # schema://user:password@host + # + # you can see how this breaks. The colon at the front delmits + # passwords and you can see the twist:// url inverts what we + # expect: + # twist://password:user@example.com + # + # twist://abc123:bob@example.com using normal conventions would + # have interpreted 'bob' as the password and 'abc123' as the user. + # For the purpose of apprise simplifying this for us, we need to + # swap these arguments when we prepare the email. + + _password = results['user'] + results['user'] = results['password'] + results['password'] = _password + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyTwist.parse_list(results['qsd']['to']) + + return results + + def __del__(self): + """ + Deconstructor + """ + try: + self.logout() + + except LookupError: + # Python v3.5 call to requests can sometimes throw the exception + # "/usr/lib64/python3.7/socket.py", line 748, in getaddrinfo + # LookupError: unknown encoding: idna + # + # This occurs every time when running unit-tests against Apprise: + # LANG=C.UTF-8 PYTHONPATH=$(pwd) py.test-3.7 + # + # There has been an open issue on this since Jan 2017. + # - https://bugs.python.org/issue29288 + # + # A ~similar~ issue can be identified here in the requests + # ticket system as unresolved and has provided work-arounds + # - https://github.com/kennethreitz/requests/issues/3578 + pass diff --git a/apprise/utils.py b/apprise/utils.py index 16d15975..0dcc6cb0 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -104,10 +104,10 @@ GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{2,9})://.*$', re.I) # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html GET_EMAIL_RE = re.compile( - r"((?P