Added Twist Support (#137)

This commit is contained in:
Chris Caron 2019-07-18 22:41:10 -04:00 committed by GitHub
parent 3124ef26a1
commit 2609680792
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 1348 additions and 4 deletions

View File

@ -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/<br />slack://TokenA/TokenB/TokenC/Channel<br />slack://botname@TokenA/TokenB/TokenC/Channel<br />slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN
| [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret<br/>twitter://user@CKey/CSecret/AKey/ASecret<br/>twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2<br/>twitter://CKey/CSecret/AKey/ASecret?mode=tweet
| [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login<br/>twist://password:login/#channel<br/>twist://password:login/#team:channel<br/>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<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port
| [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname<br />xmpp://user:password@hostname<br />xmpps://user:password@hostname:port?jid=user@hostname/resource<br/>xmpps://password@hostname/target@myhost, target2@myhost/resource
| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://

View File

@ -0,0 +1,805 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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<name>((?P<workspace>[A-Za-z0-9_-]+):)?'
r'(?P<channel>[^\s]{1,64}))$')
IS_CHANNEL_ID = re.compile(
r'^(?P<name>((?P<workspace>[0-9]+):)?(?P<channel>[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:
# <workspace_id>:<channel_id>
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:
# {
# <workspace_id>: {
# <channel_name>: <channel_id>,
# <channel_name>: <channel_id>,
# ...
# },
# <workspace2_id>: {
# <channel_name>: <channel_id>,
# <channel_name>: <channel_id>,
# ...
# },
# }
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_id>,
'workspace': <workspace_id>,
'workspace': <workspace_id>,
}
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': <channel_id>,
'channel2': <channel_id>,
'channel3': <channel_id>,
}
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

View File

@ -104,10 +104,10 @@ GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[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<label>[^+]+)\+)?"
r"(?P<fulluser>((?P<label>[^+]+)\+)?"
r"(?P<userid>[a-z0-9$%=_~-]+"
r"(?:\.[a-z0-9$%+=_~-]+)"
r"*)@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
r"*))@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
r"[a-z0-9](?:[a-z0-9-]*"
r"[a-z0-9]))?",
re.IGNORECASE,

View File

@ -51,7 +51,7 @@ Boxcar, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT,
Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications,
Microsoft Teams, Nexmo, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet,
Pushover, Rocket.Chat, Slack, Super Toasty, Stride, Telegram, Twilio, Twitter,
XBMC, XMPP, Webex Teams}
Twist, XBMC, XMPP, Webex Teams}
Name: python-%{pypi_name}
Version: 0.7.8

View File

@ -72,7 +72,7 @@ setup(
keywords='Push Notifications Alerts Email AWS SNS Boxcar Discord Dbus '
'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Mailgun '
'Matrix Mattermost Nexmo Prowl PushBullet Pushjet Pushed Pushover '
'Rocket.Chat Ryver Slack Stride Telegram Twilio Twitter XBMC '
'Rocket.Chat Ryver Slack Stride Telegram Twilio Twist Twitter XBMC '
'Microsoft MSTeams Windows Webex CLI API',
author='Chris Caron',
author_email='lead2gold@gmail.com',

View File

@ -2171,6 +2171,59 @@ TEST_URLS = (
'test_requests_exceptions': True,
}),
##################################
# NotifyTwist
##################################
('twist://', {
# Missing Email and Login
'instance': None,
}),
('twist://:@/', {
'instance': None,
}),
('twist://user@example.com/', {
# No password
'instance': None,
}),
('twist://user@example.com/password', {
# Password acceptable as first entry in path
'instance': plugins.NotifyTwist,
# Expected notify() response is False because internally we would
# have failed to login
'notify_response': False,
}),
('twist://password:user1@example.com', {
# password:login acceptable
'instance': plugins.NotifyTwist,
# Expected notify() response is False because internally we would
# have failed to login
'notify_response': False,
}),
('twist://password:user2@example.com', {
# password:login acceptable
'instance': plugins.NotifyTwist,
# Expected notify() response is False because internally we would
# have logged in, but we would have failed to look up the #General
# channel and workspace.
'requests_response_text': {
# Login expected response
'id': 1234,
'default_workspace': 9876,
},
'notify_response': False,
}),
('twist://password:user2@example.com', {
'instance': plugins.NotifyTwist,
# throw a bizzare code forcing us to fail to look it up
'response': False,
'requests_response_code': 999,
}),
('twist://password:user2@example.com', {
'instance': plugins.NotifyTwist,
# Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them
'test_requests_exceptions': True,
}),
##################################
# NotifyTwitter
##################################

485
test/test_twist_plugin.py Normal file
View File

@ -0,0 +1,485 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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 mock
import requests
from json import dumps
from apprise import plugins
from apprise import Apprise
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_twist_plugin_init():
"""
API: NotifyTwist init()
"""
try:
plugins.NotifyTwist(email='invalid', targets=None)
assert False
except TypeError:
# Invalid email address
assert True
try:
plugins.NotifyTwist(email='user@domain', targets=None)
assert False
except TypeError:
# No password was specified
assert True
# Simple object initialization
result = plugins.NotifyTwist(
password='abc123', email='user@domain.com', targets=None)
assert result.user == 'user'
assert result.host == 'domain.com'
assert result.password == 'abc123'
# Channel Instantiation by name
obj = Apprise.instantiate('twist://password:user@example.com/#Channel')
assert isinstance(obj, plugins.NotifyTwist)
# Channel Instantiation by id (faster if you know the translation)
obj = Apprise.instantiate('twist://password:user@example.com/12345')
assert isinstance(obj, plugins.NotifyTwist)
# Invalid Channel - (max characters is 64), the below drops it
obj = Apprise.instantiate(
'twist://password:user@example.com/{}'.format('a' * 65))
assert isinstance(obj, plugins.NotifyTwist)
# No User detect
result = plugins.NotifyTwist.parse_url('twist://example.com')
assert result is None
# test usage of to=
result = plugins.NotifyTwist.parse_url(
'twist://password:user@example.com?to=#channel')
assert isinstance(result, dict)
assert 'user' in result
assert result['user'] == 'user'
assert 'host' in result
assert result['host'] == 'example.com'
assert 'password' in result
assert result['password'] == 'password'
assert 'targets' in result
assert isinstance(result['targets'], list) is True
assert len(result['targets']) == 1
assert '#channel' in result['targets']
@mock.patch('requests.get')
@mock.patch('requests.post')
def test_twist_plugin_auth(mock_post, mock_get):
"""
API: NotifyTwist login/logout()
"""
# Disable Throttling to speed testing
plugins.NotifyBase.request_rate_per_sec = 0
# 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_post.return_value.content = dumps({
'token': '2e82c1e4e8b0091fdaa34ff3972351821406f796',
'default_workspace': 12345,
})
mock_get.return_value.content = mock_post.return_value.content
# Instantiate an object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel')
assert isinstance(obj, plugins.NotifyTwist)
# not logged in yet
obj.logout()
assert obj.login() is True
# Clear our channel listing
obj.channels.clear()
# No channels mean there is no internal migration/lookups required
assert obj._channel_migration() is True
# Workspace Success
mock_post.return_value.content = dumps([
{
'name': 'TesT',
'id': 1,
}, {
'name': 'tESt2',
'id': 2,
},
])
mock_get.return_value.content = mock_post.return_value.content
results = obj.get_workspaces()
assert len(results) == 2
assert 'test' in results
assert results['test'] == 1
assert 'test2' in results
assert results['test2'] == 2
mock_post.return_value.content = dumps([
{
'name': 'ChaNNEL1',
'id': 1,
}, {
'name': 'chaNNel2',
'id': 2,
},
])
mock_get.return_value.content = mock_post.return_value.content
results = obj.get_channels(wid=1)
assert len(results) == 2
assert 'channel1' in results
assert results['channel1'] == 1
assert 'channel2' in results
assert results['channel2'] == 2
# Test result failure response
mock_post.return_value.status_code = 403
mock_get.return_value.status_code = 403
assert obj.get_workspaces() == dict()
# Return things how they were
mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok
# Forces call to logout:
del obj
#
# Authentication failures
#
mock_post.return_value.status_code = 403
mock_get.return_value.status_code = 403
# Instantiate an object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel')
assert isinstance(obj, plugins.NotifyTwist)
# Authentication failed
assert obj.get_workspaces() == dict()
assert obj.get_channels(wid=1) == dict()
assert obj._channel_migration() is False
assert obj.send('body', 'title') is False
obj = Apprise.instantiate('twist://password:user@example.com/#Channel')
assert isinstance(obj, plugins.NotifyTwist)
# Calling logout on an object already logged out
obj.logout()
# Force a token (to imply we've logged in)
obj.token = 'abc'
mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok
# Test Python v3.5 LookupError Bug: https://bugs.python.org/issue29288
mock_post.side_effect = LookupError()
mock_get.side_effect = LookupError()
obj.access_token = 'abc'
obj.user_id = '123'
# Tidy object
del obj
@mock.patch('requests.get')
@mock.patch('requests.post')
def test_twist_plugin_cache(mock_post, mock_get):
"""
API: NotifyTwist cache()
Test cache handling
"""
# Disable Throttling to speed testing
plugins.NotifyBase.request_rate_per_sec = 0
def _response(url, *args, **kwargs):
# Default configuration
request = mock.Mock()
request.status_code = requests.codes.ok
request.content = '{}'
if url.endswith('/login'):
# Simulate a successful login
request.content = dumps({
'token': '2e82c1e4e8b0091fdaa34ff3972351821406f796',
'default_workspace': 1,
})
elif url.endswith('workspaces/get'):
request.content = dumps([
{
'name': 'TeamA',
'id': 1,
}, {
'name': 'TeamB',
'id': 2,
},
])
elif url.endswith('channels/get'):
request.content = dumps([
{
'name': 'ChanA',
'id': 1,
}, {
'name': 'ChanB',
'id': 2,
},
])
return request
mock_get.side_effect = _response
mock_post.side_effect = _response
# Instantiate an object
obj = Apprise.instantiate(
'twist://password:user@example.com/'
'#ChanB/1:1/TeamA:ChanA/Ignore:Chan/3:1')
assert isinstance(obj, plugins.NotifyTwist)
# Will detect channels except Ignore:Chan
assert obj._channel_migration() is False
# Add another channel
obj.channels.add('ChanB')
assert obj._channel_migration() is True
# Nothing more to detect the second time around
assert obj._channel_migration() is True
# Send a notification
assert obj.send('body', 'title') is True
def _can_not_send_response(url, *args, **kwargs):
"""
Simulate a case where we can't send a notification
"""
# Force a failure
request = mock.Mock()
request.status_code = 403
request.content = '{}'
return request
mock_get.side_effect = _can_not_send_response
mock_post.side_effect = _can_not_send_response
# Send a notification and fail at it
assert obj.send('body', 'title') is False
@mock.patch('requests.get')
@mock.patch('requests.post')
def test_twist_plugin_fetch(mock_post, mock_get):
"""
API: NotifyTwist fetch()
fetch() is a wrapper that handles all kinds of edge cases and even
attempts to re-authenticate to the Twist server if our token
happens to expire. This tests these edge cases
"""
# Disable Throttling to speed testing
plugins.NotifyBase.request_rate_per_sec = 0
# Track our iteration; by tracing within an object, we can re-reference
# it within a function scope.
_cache = {
'first_time': True,
}
def _reauth_response(url, *args, **kwargs):
"""
Tests re-authentication process and then a successful
retry
"""
# Default configuration
request = mock.Mock()
request.status_code = requests.codes.ok
# Simulate a successful login
request.content = dumps({
'token': '2e82c1e4e8b0091fdaa34ff3972351821406f796',
'default_workspace': 12345,
})
if url.endswith('threads/add') and _cache['first_time'] is True:
# First time iteration; act as if we failed; our second iteration
# will not enter this and be successful. This is done by simply
# toggling the first_time flag:
_cache['first_time'] = False
# otherwise, we set our first-time failure settings
request.status_code = 403
request.content = dumps({
'error_code': 200,
'error_string': 'Invalid token',
})
return request
mock_get.side_effect = _reauth_response
mock_post.side_effect = _reauth_response
# Instantiate an object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel/34')
assert isinstance(obj, plugins.NotifyTwist)
# Simulate a re-authentication
postokay, response = obj._fetch('threads/add')
##########################################################################
_cache = {
'first_time': True,
}
def _reauth_exception_response(url, *args, **kwargs):
"""
Tests exception thrown after re-authentication process
"""
# Default configuration
request = mock.Mock()
request.status_code = requests.codes.ok
# Simulate a successful login
request.content = dumps({
'token': '2e82c1e4e8b0091fdaa34ff3972351821406f796',
'default_workspace': 12345,
})
if url.endswith('threads/add') and _cache['first_time'] is True:
# First time iteration; act as if we failed; our second iteration
# will not enter this and be successful. This is done by simply
# toggling the first_time flag:
_cache['first_time'] = False
# otherwise, we set our first-time failure settings
request.status_code = 403
request.content = dumps({
'error_code': 200,
'error_string': 'Invalid token',
})
elif url.endswith('threads/add') and _cache['first_time'] is False:
# unparseable response throws the exception
request.status_code = 200
request.content = '{'
return request
mock_get.side_effect = _reauth_exception_response
mock_post.side_effect = _reauth_exception_response
# Instantiate an object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel/34')
assert isinstance(obj, plugins.NotifyTwist)
# Simulate a re-authentication
postokay, response = obj._fetch('threads/add')
##########################################################################
_cache = {
'first_time': True,
}
def _reauth_failed_response(url, *args, **kwargs):
"""
Tests re-authentication process and have it not succeed
"""
# Default configuration
request = mock.Mock()
request.status_code = requests.codes.ok
# Simulate a successful login
request.content = dumps({
'token': '2e82c1e4e8b0091fdaa34ff3972351821406f796',
'default_workspace': 12345,
})
if url.endswith('threads/add') and _cache['first_time'] is True:
# First time iteration; act as if we failed; our second iteration
# will not enter this and be successful. This is done by simply
# toggling the first_time flag:
_cache['first_time'] = False
# otherwise, we set our first-time failure settings
request.status_code = 403
request.content = dumps({
'error_code': 200,
'error_string': 'Invalid token',
})
elif url.endswith('/login') and _cache['first_time'] is False:
# Fail to login
request.status_code = 403
request.content = '{}'
return request
mock_get.side_effect = _reauth_failed_response
mock_post.side_effect = _reauth_failed_response
# Instantiate an object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel/34')
assert isinstance(obj, plugins.NotifyTwist)
# Simulate a re-authentication
postokay, response = obj._fetch('threads/add')
def _unparseable_json_response(url, *args, **kwargs):
# Default configuration
request = mock.Mock()
request.status_code = requests.codes.ok
request.content = '{'
return request
mock_get.side_effect = _unparseable_json_response
mock_post.side_effect = _unparseable_json_response
# Instantiate our object
obj = Apprise.instantiate('twist://password:user@example.com/#Channel/34')
assert isinstance(obj, plugins.NotifyTwist)
# Simulate a re-authentication
postokay, response = obj._fetch('threads/add')
assert postokay is True
# When we can't parse the content, we still default to an empty
# dictionary
assert response == {}