diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b0986195 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# vi swap files +.*.sw? + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..331fe132 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +python: + - "2.7" + +install: + - pip install . + - pip install coveralls + - pip install -r requirements.txt + +after_success: + - coveralls + +# run tests +script: nosetests --with-coverage --cover-package=apprise + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..ef7c56ca --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE +include README.md +include requirements.txt +recursive-include test * +global-exclude *.pyc +global-exclude __pycache__ diff --git a/README.md b/README.md new file mode 100644 index 00000000..863c7690 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +
+ +**ap·prise** / *verb*
+To inform or tell (someone). To make one aware of something. +
+ +*Apprise* allows you to take advantage of *just about* every notification service available to us today. Send a notification to almost all of the most popular services out there today (such as Telegram, Slack, Twitter, etc). The ones that don't exist can be adapted and supported too! + +## Supported Notifications +The section identifies all of the services supported by this script. + +### Popular Notification Services +The table below identifies the services this tool supports and some example service urls you need to use in order to take advantage of it. + +| 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/alias
boxcar://hostname/@tag/@tag2/alias/device_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/ +| [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
+| [Notify My Android](https://github.com/caronc/apprise/wiki/Notify_my_android) | nma:// | (TCP) 443 | nma://apikey +| [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey +| [Pushalot](https://github.com/caronc/apprise/wiki/Notify_pushalot) | palot:// | (TCP) 443 | palot://authorizationtoken +| [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE +| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// | (TCP) 80 | pjet://secret
pjet://secret@hostname
pjet://secret@hostname:port
pjets://secret@hostname
pjets://secret@hostname:port
Note: if no hostname defined https://api.pushjet.io will be used +| [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
_Note: you must specify both your user_id and token_ +| [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 +| [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 +| [Super Toasty](https://github.com/caronc/apprise/wiki/Notify_toasty) | toasty:// | (TCP) 80 | toasty://user@DEVICE
toasty://user@DEVICE1/DEVICE2/DEVICEN
_Note: you must specify both your user_id and at least 1 device!_ +| [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 + +### Email Support +| Service ID | Default Port | Example Syntax | +| ---------- | ------------ | -------------- | +| [mailto://](https://github.com/caronc/apprise/wiki/Notify_email) | (TCP) 25 | mailto://userid:pass@domain.com
mailto://domain.com?user=userid&pass=password
mailto://domain.com:2525?user=userid&pass=password
mailto://user@gmail.com&pass=password
mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply +| [mailtos//](https://github.com/caronc/apprise/wiki/Notify_email) | (TCP) 587 | mailtos://userid:pass@domain.com
mailtos://domain.com?user=userid&pass=password
mailtos://domain.com:465?user=userid&pass=password
mailtos://user@hotmail.com&pass=password
mailtos://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply + +Apprise have some email services built right into it (such as hotmail, gmail, etc) that greatly simplify the mailto:// service. See more details [here](https://github.com/caronc/apprise/wiki/Notify_email). + +### Custom Notifications +| Post Method | Service ID | Default Port | Example Syntax | +| -------------------- | ---------- | ------------ | -------------- | +| [JSON](https://github.com/caronc/apprise/wiki/Notify_Custom_JSON) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to +| [XML](https://github.com/caronc/apprise/wiki/Notify_Custom_XML) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to + diff --git a/apprise/Apprise.py b/apprise/Apprise.py new file mode 100644 index 00000000..a73da168 --- /dev/null +++ b/apprise/Apprise.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +import re +import logging + +from . import plugins +from .Utils import parse_url +from .Utils import parse_list +from .Utils import parse_bool + +logger = logging.getLogger(__name__) + +# Build a list of supported plugins +SCHEMA_MAP = {} + + +# Load our Lookup Matrix +def __load_matrix(): + """ + Dynamically load our schema map; this allows us to gracefully + skip over plugins we simply don't have the dependecies for. + + """ + # to add it's mapping to our hash table + for entry in dir(plugins): + # Get our plugin + plugin = getattr(plugins, entry) + + proto = getattr(plugin, 'PROTOCOL', None) + protos = getattr(plugin, 'SECURE_PROTOCOL', None) + if not proto: + # Must have at least PROTOCOL defined + continue + + if proto not in SCHEMA_MAP: + SCHEMA_MAP[proto] = plugin + + if protos and protos not in SCHEMA_MAP: + SCHEMA_MAP[protos] = plugin + + +# Dynamically build our module +__load_matrix() + + +class Apprise(object): + """ + Our Notification Manager + + """ + def __init__(self, servers=None): + """ + Loads a set of server urls + + """ + + # Initialize a server list of URLs + self.servers = list() + + if servers: + self.add(servers) + + def add(self, servers, include_image=True, image_url=None, + image_path=None): + """ + Adds one or more server URLs into our list. + + """ + + servers = parse_list(servers) + for _server in servers: + + # swap hash (#) tag values with their html version + # This is useful for accepting channels (as arguments to + # pushbullet) + _server = _server.replace('/#', '/%23') + + # Parse our url details + # the server object is a dictionary containing all of the + # information parsed from our URL + server = parse_url(_server, default_schema='unknown') + + # Initialize our return status + return_status = True + + if not server: + # This is a dirty hack; but it's the only work around to + # tgram:// messages since the bot_token has a colon in it. + # It invalidates an normal URL. + + # This hack searches for this bogus URL and corrects it + # so we can properly load it further down. The other + # alternative is to ask users to actually change the colon + # into a slash (which will work too), but it's more likely + # to cause confusion... So this is the next best thing + tgram = re.match( + r'(?P%s://)(bot)?(?P([a-z0-9_-]+)' + r'(:[a-z0-9_-]+)?@)?(?P[0-9]+):+' + r'(?P.*)$' % 'tgram', + _server, re.I) + + if tgram: + if tgram.group('prefix'): + server = self.parse_url('%s%s%s/%s' % ( + tgram.group('protocol'), + tgram.group('prefix'), + tgram.group('btoken_a'), + tgram.group('remaining'), + ), + default_schema='unknown', + ) + + else: + server = self.parse_url('%s%s/%s' % ( + tgram.group('protocol'), + tgram.group('btoken_a'), + tgram.group('remaining'), + ), + default_schema='unknown', + ) + + if not server: + # Failed to parse te server + self.logger.error('Could not parse URL: %s' % server) + return_status = False + continue + + # Some basic validation + if server['schema'] not in SCHEMA_MAP: + self.logger.error( + '%s is not a supported server type.' % + server['schema'].upper(), + ) + return_status = False + continue + + notify_args = server.copy().items() + { + # Logger Details + 'logger': self.logger, + # Base + 'include_image': include_image, + 'secure': (server['schema'][-1] == 's'), + # Support SSL Certificate 'verify' keyword + # Default to being enabled (True) + 'verify': parse_bool(server['qsd'].get('verify', True)), + # Overrides + 'override_image_url': image_url, + 'override_image_path': image_path, + }.items() + + # Grant our plugin access to manipulate the dictionary + if not SCHEMA_MAP[server['schema']].pre_parse(notify_args): + # the arguments are invalid or can not be used. + return_status = False + continue + + # Add our entry to our list as it can be actioned at this point + self.servers.add(notify_args) + + # Return our status + return return_status + + def clear(self, urls): + """ + Empties our server list + + """ + self.servers.clear() + + def notify(self, title='', body=''): + """ + Notifies all loaded servers using the content provided. + + """ + # TODO: iterate over server entries and execute notification diff --git a/apprise/Utils.py b/apprise/Utils.py new file mode 100644 index 00000000..3227d915 --- /dev/null +++ b/apprise/Utils.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +# +# A simple collection of general functions +# +# Copyright (C) 2017 Chris Caron +# +# 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. + +import re + +from os.path import expanduser + +from urlparse import urlparse +from urlparse import parse_qsl +from urllib import quote +from urllib import unquote + +import logging +logger = logging.getLogger(__name__) + +# URL Indexing Table for returns via parse_url() +VALID_URL_RE = re.compile(r'^[\s]*([^:\s]+):[/\\]*([^?]+)(\?(.+))?[\s]*$') +VALID_HOST_RE = re.compile(r'^[\s]*([^:/\s]+)') +VALID_QUERY_RE = re.compile(r'^(.*[/\\])([^/\\]*)$') + +# delimiters used to separate values when content is passed in by string. +# This is useful when turning a string into a list +STRING_DELIMITERS = r'[\[\]\;,\s]+' + +# Pre-Escape content since we reference it so much +ESCAPED_PATH_SEPARATOR = re.escape('\\/') +ESCAPED_WIN_PATH_SEPARATOR = re.escape('\\') +ESCAPED_NUX_PATH_SEPARATOR = re.escape('/') + +TIDY_WIN_PATH_RE = re.compile( + '(^[%s]{2}|[^%s\s][%s]|[\s][%s]{2}])([%s]+)' % ( + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ), +) +TIDY_WIN_TRIM_RE = re.compile( + '^(.+[^:][^%s])[\s%s]*$' % ( + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ), +) + +TIDY_NUX_PATH_RE = re.compile( + '([%s])([%s]+)' % ( + ESCAPED_NUX_PATH_SEPARATOR, + ESCAPED_NUX_PATH_SEPARATOR, + ), +) + +TIDY_NUX_TRIM_RE = re.compile( + '([^%s])[\s%s]+$' % ( + ESCAPED_NUX_PATH_SEPARATOR, + ESCAPED_NUX_PATH_SEPARATOR, + ), +) + + +def tidy_path(path): + """take a filename and or directory and attempts to tidy it up by removing + trailing slashes and correcting any formatting issues. + + For example: ////absolute//path// becomes: + /absolute/path + + """ + # Windows + path = TIDY_WIN_PATH_RE.sub('\\1', path.strip()) + # Linux + path = TIDY_NUX_PATH_RE.sub('\\1', path.strip()) + + # Linux Based Trim + path = TIDY_NUX_TRIM_RE.sub('\\1', path.strip()) + # Windows Based Trim + path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path.strip())) + return path + + +def parse_url(url, default_schema='http'): + """A function that greatly simplifies the parsing of a url + specified by the end user. + + Valid syntaxes are: + ://@:/ + ://:@:/ + ://:/ + :/// + :// + + Argument parsing is also supported: + ://@:/?key1=val&key2=val2 + ://:@:/?key1=val&key2=val2 + ://:/?key1=val&key2=val2 + :///?key1=val&key2=val2 + ://?key1=val&key2=val2 + + The function returns a simple dictionary with all of + the parsed content within it and returns 'None' if the + content could not be extracted. + """ + + if not isinstance(url, basestring): + # Simple error checking + return None + + # Default Results + result = { + # The username (if specified) + 'user': None, + # The password (if specified) + 'password': None, + # The port (if specified) + 'port': None, + # The hostname + 'host': None, + # The full path (query + path) + 'fullpath': None, + # The path + 'path': None, + # The query + 'query': None, + # The schema + 'schema': None, + # The schema + 'url': None, + # The arguments passed in (the parsed query) + # This is in a dictionary of {'key': 'val', etc } + # qsd = Query String Dictionary + 'qsd': {} + } + + qsdata = '' + match = VALID_URL_RE.search(url) + if match: + # Extract basic results + result['schema'] = match.group(1).lower().strip() + host = match.group(2).strip() + try: + qsdata = match.group(4).strip() + except AttributeError: + # No qsdata + pass + else: + match = VALID_HOST_RE.search(url) + if not match: + return None + result['schema'] = default_schema + host = match.group(1).strip() + + if not result['schema']: + result['schema'] = default_schema + + if not host: + # Invalid Hostname + return None + + # Now do a proper extraction of data + parsed = urlparse('http://%s' % host) + + # Parse results + result['host'] = parsed[1].strip() + result['fullpath'] = quote(unquote(tidy_path(parsed[2].strip()))) + try: + # Handle trailing slashes removed by tidy_path + if result['fullpath'][-1] not in ('/', '\\') and \ + url[-1] in ('/', '\\'): + result['fullpath'] += url.strip()[-1] + + except IndexError: + # No problem, there simply isn't any returned results + # and therefore, no trailing slash + pass + + # Parse Query Arugments ?val=key&key=val + # while ensureing that all keys are lowercase + if qsdata: + result['qsd'] = dict([(k.lower().strip(), v.strip()) + for k, v in parse_qsl( + qsdata, + keep_blank_values=True, + strict_parsing=False, + )]) + + if not result['fullpath']: + # Default + result['fullpath'] = None + else: + # Using full path, extract query from path + match = VALID_QUERY_RE.search(result['fullpath']) + if match: + result['path'] = match.group(1) + result['query'] = match.group(2) + if not result['path']: + result['path'] = None + if not result['query']: + result['query'] = None + try: + (result['user'], result['host']) = \ + re.split('[\s@]+', result['host'])[:2] + + except ValueError: + # no problem then, host only exists + # and it's already assigned + pass + + if result['user'] is not None: + try: + (result['user'], result['password']) = \ + re.split('[:\s]+', result['user'])[:2] + + except ValueError: + # no problem then, user only exists + # and it's already assigned + pass + + try: + (result['host'], result['port']) = \ + re.split('[\s:]+', result['host'])[:2] + + except ValueError: + # no problem then, user only exists + # and it's already assigned + pass + + if result['port']: + try: + result['port'] = int(result['port']) + except (ValueError, TypeError): + # Invalid Port Specified + return None + if result['port'] == 0: + result['port'] = None + + # Re-assemble cleaned up version of the url + result['url'] = '%s://' % result['schema'] + if isinstance(result['user'], basestring): + result['url'] += result['user'] + if isinstance(result['password'], basestring): + result['url'] += ':%s@' % result['password'] + else: + result['url'] += '@' + result['url'] += result['host'] + + if result['port']: + result['url'] += ':%d' % result['port'] + + if result['fullpath']: + result['url'] += result['fullpath'] + + return result + + +def parse_bool(arg, default=False): + """ + NZBGet uses 'yes' and 'no' as well as other strings such as 'on' or + 'off' etch to handle boolean operations from it's control interface. + + This method can just simplify checks to these variables. + + If the content could not be parsed, then the default is returned. + """ + + if isinstance(arg, basestring): + # no = no - False + # of = short for off - False + # 0 = int for False + # fa = short for False - False + # f = short for False - False + # n = short for No or Never - False + # ne = short for Never - False + # di = short for Disable(d) - False + # de = short for Deny - False + if arg.lower()[0:2] in ( + 'de', 'di', 'ne', 'f', 'n', 'no', 'of', '0', 'fa'): + return False + # ye = yes - True + # on = short for off - True + # 1 = int for True + # tr = short for True - True + # t = short for True - True + # al = short for Always (and Allow) - True + # en = short for Enable(d) - True + elif arg.lower()[0:2] in ( + 'en', 'al', 't', 'y', 'ye', 'on', '1', 'tr'): + return True + # otherwise + return default + + # Handle other types + return bool(arg) + + +def parse_list(*args): + """ + Take a string list and break it into a delimited + list of arguments. This funciton also supports + the processing of a list of delmited strings and will + always return a unique set of arguments. Duplicates are + always combined in the final results. + + You can append as many items to the argument listing for + parsing. + + Hence: parse_list('.mkv, .iso, .avi') becomes: + ['.mkv', '.iso', '.avi'] + + Hence: parse_list('.mkv, .iso, .avi', ['.avi', '.mp4']) becomes: + ['.mkv', '.iso', '.avi', '.mp4'] + + The parsing is very forgiving and accepts spaces, slashes, commas + semicolons, and pipes as delimiters + """ + + result = [] + for arg in args: + if isinstance(arg, basestring): + result += re.split(STRING_DELIMITERS, arg) + + elif isinstance(arg, (list, tuple)): + for _arg in arg: + if isinstance(arg, basestring): + result += re.split(STRING_DELIMITERS, arg) + # A list inside a list? - use recursion + elif isinstance(_arg, (list, tuple)): + result += parse_list(_arg) + else: + # Convert whatever it is to a string and work with it + result += parse_list(str(_arg)) + else: + # Convert whatever it is to a string and work with it + result += parse_list(str(arg)) + + # apply as well as make the list unique by converting it + # to a set() first. filter() eliminates any empty entries + return filter(bool, list(set(result))) diff --git a/apprise/__init__.py b/apprise/__init__.py new file mode 100644 index 00000000..5bc2cc79 --- /dev/null +++ b/apprise/__init__.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +# +# Supported Push Notifications Libraries +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from .Apprise import Apprise + +__version__ = '0.0.1' +__author__ = 'Chris Caron ' + +__all__ = [ + # Core + 'Apprise', +] diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py new file mode 100644 index 00000000..349ba8fc --- /dev/null +++ b/apprise/plugins/NotifyBase.py @@ -0,0 +1,445 @@ +# -*- encoding: utf-8 -*- +# +# Base Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from time import sleep +import re + +import markdown + +import logging + +from os.path import join +from os.path import dirname +from os.path import abspath + +# For conversion +from chardet import detect as chardet_detect + +# Define a general HTML Escaping +try: + # use sax first because it's faster + from xml.sax.saxutils import escape as sax_escape + + def _escape(text): + """ + saxutil escape tool + """ + return sax_escape(text, {"'": "'", "\"": """}) + +except ImportError: + # if we can't, then fall back to cgi escape + from cgi import escape as cgi_escape + + def _escape(text): + """ + cgi escape tool + """ + return cgi_escape(text, quote=True) + + +class NotifyType(object): + INFO = 'info' + SUCCESS = 'success' + FAILURE = 'failure' + WARNING = 'warning' + + +# Most Servers do not like more then 1 request per 5 seconds, +# so 5.5 gives us a safe play range... +NOTIFY_THROTTLE_SEC = 5.5 + +NOTIFY_TYPES = ( + NotifyType.INFO, + NotifyType.SUCCESS, + NotifyType.FAILURE, + NotifyType.WARNING, +) + +# A Simple Mapping of Colors; For every NOTIFY_TYPE identified, +# there should be a mapping to it's color here: +HTML_NOTIFY_MAP = { + NotifyType.INFO: '#3AA3E3', + NotifyType.SUCCESS: '#3AA337', + NotifyType.FAILURE: '#A32037', + NotifyType.WARNING: '#CACF29', +} + + +class NotifyImageSize(object): + XY_72 = '72x72' + XY_128 = '128x128' + XY_256 = '256x256' + + +NOTIFY_IMAGE_SIZES = ( + NotifyImageSize.XY_72, + NotifyImageSize.XY_128, + NotifyImageSize.XY_256, +) + +HTTP_ERROR_MAP = { + 400: 'Bad Request - Unsupported Parameters.', + 401: 'Verification Failed.', + 404: 'Page not found.', + 405: 'Method not allowed.', + 500: 'Internal server error.', + 503: 'Servers are overloaded.', +} + +# Application Identifier +NOTIFY_APPLICATION_ID = 'apprise' +NOTIFY_APPLICATION_DESC = 'Apprise Notifications' + +# Image Control +NOTIFY_IMAGE_URL = \ + 'http://nuxref.com/apprise/apprise-{TYPE}-{XY}.png' + +NOTIFY_IMAGE_FILE = abspath(join( + dirname(__file__), + 'var', + 'apprise-{TYPE}-{XY}.png', +)) + +# HTML New Line Delimiter +NOTIFY_NEWLINE = '\r\n' + + +class NotifyFormat(object): + TEXT = 'text' + HTML = 'html' + + +NOTIFY_FORMATS = ( + NotifyFormat.TEXT, + NotifyFormat.HTML, +) + +# Regular expression retrieved from: +# http://www.regular-expressions.info/email.html +IS_EMAIL_RE = re.compile( + r"(?P[a-z0-9!#$%&'*+/=?^_`{|}~-]+" + r"(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)" + r"*)@(?P(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+" + r"[a-z0-9](?:[a-z0-9-]*" + r"[a-z0-9]))?", + re.IGNORECASE, +) + + +class NotifyBase(object): + """ + This is the base class for all notification services + """ + + # The default simple (insecure) protocol + # all inheriting entries must provide their protocol lookup + # protocol:// (in this example they would specify 'protocol') + PROTOCOL = '' + + # The default secure protocol + # all inheriting entries must provide their protocol lookup + # protocols:// (in this example they would specify 'protocols') + # This value can be the same as the defined PROTOCOL. + SECURE_PROTOCOL = '' + + def __init__(self, title_maxlen=100, body_maxlen=512, + notify_format=NotifyFormat.TEXT, image_size=None, + include_image=False, override_image_path=None, + secure=False, **kwargs): + """ + Initialize some general logging and common server arguments + that will keep things consistent when working with the + notifiers that will inherit this class + """ + + # Logging + self.logger = logging.getLogger(__name__) + + if notify_format.lower() not in NOTIFY_FORMATS: + self.logger.error( + 'Invalid notification format %s' % notify_format, + ) + raise TypeError( + 'Invalid notification format %s' % notify_format, + ) + + if image_size and image_size not in NOTIFY_IMAGE_SIZES: + self.logger.error( + 'Invalid image size %s' % image_size, + ) + raise TypeError( + 'Invalid image size %s' % image_size, + ) + + self.app_id = NOTIFY_APPLICATION_ID + self.app_desc = NOTIFY_APPLICATION_DESC + + self.notify_format = notify_format.lower() + self.title_maxlen = title_maxlen + self.body_maxlen = body_maxlen + self.image_size = image_size + self.include_image = include_image + self.secure = secure + + # Certificate Verification (for SSL calls); default to being enabled + self.verify_certificate = kwargs.get('verify', True) + + self.host = kwargs.get('host', '') + self.port = kwargs.get('port') + if self.port: + try: + self.port = int(self.port) + except (TypeError, ValueError): + self.port = None + + self.user = kwargs.get('user') + self.password = kwargs.get('password') + + # Over-rides + self.override_image_url = kwargs.get('override_image_url') + self.override_image_path = kwargs.get('override_image_path') + + def throttle(self, throttle_time=NOTIFY_THROTTLE_SEC): + """ + A common throttle control + """ + self.logger.debug('Throttling...') + sleep(throttle_time) + return + + def image_url(self, notify_type): + """ + Returns Image URL if possible + """ + + if self.override_image_url: + # Over-ride + return self.override_image_url + + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + re_map = { + '{TYPE}': notify_type, + '{XY}': self.image_size, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + return re_table.sub(lambda x: re_map[x.group()], NOTIFY_IMAGE_URL) + + def image_raw(self, notify_type): + """ + Returns the raw image if it can + """ + if not self.override_image_path: + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + re_map = { + '{TYPE}': notify_type, + '{XY}': self.image_size, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + # Now we open and return the file + _file = re_table.sub( + lambda x: re_map[x.group()], NOTIFY_IMAGE_FILE) + + else: + # Override Path Specified + _file = self.override_image_path + + try: + fd = open(_file, 'rb') + + except: + return None + + try: + return fd.read() + + except: + return None + + finally: + fd.close() + + def escape_html(self, html, convert_new_lines=False): + """ + Takes html text as input and escapes it so that it won't + conflict with any xml/html wrapping characters. + """ + escaped = _escape(html).\ + replace(u'\t', u' ').\ + replace(u' ', u'  ') + + if convert_new_lines: + return escaped.replace(u'\n', u'
') + + return escaped + + def to_utf8(self, content): + """ + Attempts to convert non-utf8 content to... (you guessed it) utf8 + """ + if not content: + return '' + + if isinstance(content, unicode): + return content.encode('utf-8') + + result = chardet_detect(content) + encoding = result['encoding'] + try: + content = content.decode( + encoding, + errors='replace', + ) + return content.encode('utf-8') + + except UnicodeError: + raise ValueError( + '%s contains invalid characters' % ( + content)) + + except KeyError: + raise ValueError( + '%s encoding could not be detected ' % ( + content)) + + except TypeError: + try: + content = content.decode( + encoding, + 'replace', + ) + return content.encode('utf-8') + + except UnicodeError: + raise ValueError( + '%s contains invalid characters' % ( + content)) + + except KeyError: + raise ValueError( + '%s encoding could not be detected ' % ( + content)) + + return '' + + def to_html(self, body): + """ + Returns the specified title in an html format and factors + in a titles defined max length + """ + html = markdown.markdown(body) + + # TODO: + # This function should return multiple messages if we exceed + # the maximum number of characters. the second message should + + # The new message should factor in the title and add ' cont...' + # to the end of it. It should also include the added characters + # put in place by the html characters. So there is a little bit + # of math and manipulation that needs to go on here. + # we always return a list + return [html, ] + + def notify(self, title, body, notify_type=NotifyType.SUCCESS, + **kwargs): + """ + This should be over-rided by the class that + inherits this one. + """ + if notify_type and notify_type not in NOTIFY_TYPES: + self.warning( + 'An invalid notification type (%s) was specified.' % ( + notify_type)) + + if not isinstance(body, basestring): + body = '' + + if not isinstance(title, basestring): + title = '' + + # Ensure we're set up as UTF-8 + title = self.to_utf8(title) + body = self.to_utf8(body) + + if title: + title = title[0:self.title_maxlen] + + if self.notify_format == NotifyFormat.HTML: + bodies = self.to_html(body=body) + + elif self.notify_format == NotifyFormat.TEXT: + # TODO: this should split the content into + # multiple messages + bodies = [body[0:self.body_maxlen], ] + + while len(bodies): + b = bodies.pop(0) + # Send Message(s) + if not self._notify( + title=title, body=b, + notify_type=notify_type, + **kwargs): + return False + + # If we got here, we sent part of the notification + # if there are any left, we should throttle so we + # don't overload the server with requests (they + # might not be happy with us otherwise) + if len(bodies): + self.throttle() + + return True + + def pre_parse(self, url, server_settings): + """ + grants the ability to manipulate or additionally parse the content + provided in the server_settings variable. + + Return True if you're satisfied with them (and may have additionally + changed them) and False if the settings are not acceptable or useable + + Since this is the base class, plugins are not requird to overload it + but have the option to. By default the configuration is always + accepted. + + """ + return True diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py new file mode 100644 index 00000000..4eb9cb0e --- /dev/null +++ b/apprise/plugins/NotifyBoxcar.py @@ -0,0 +1,178 @@ +# -*- encoding: utf-8 -*- +# +# Boxcar Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests +import re + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +# Used to validate Tags, Aliases and Devices +IS_TAG = re.compile(r'^[A-Za-z0-9]{1,63}$') +IS_ALIAS = re.compile(r'^[@]?[A-Za-z0-9]+$') +IS_DEVICETOKEN = re.compile(r'^[A-Za-z0-9]{64}$') + + +class NotifyBoxcar(NotifyBase): + """ + A wrapper for Boxcar Notifications + """ + + # The default simple (insecure) protocol + PROTOCOL = 'boxcar' + + # The default secure protocol + SECURE_PROTOCOL = 'boxcars' + + def __init__(self, recipients=None, **kwargs): + """ + Initialize Boxcar Object + """ + super(NotifyBoxcar, self).__init__( + title_maxlen=250, body_maxlen=10000, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if self.secure: + self.schema = 'https' + else: + self.schema = 'http' + + # Initialize tag list + self.tags = list() + # Initialize alias list + self.aliases = list() + # Initialize device_token list + self.device_tokens = list() + + if recipients is None: + recipients = [] + + elif isinstance(recipients, basestring): + recipients = filter(bool, TAGS_LIST_DELIM.split( + recipients, + )) + + elif not isinstance(recipients, (tuple, list)): + recipients = [] + + # Validate recipients and drop bad ones: + for recipient in recipients: + if IS_DEVICETOKEN.match(recipient): + # store valid device + self.device_tokens.append(recipient) + + elif IS_TAG.match(recipient): + # store valid tag + self.tags.append(recipient) + + elif IS_ALIAS.match(recipient): + # store valid tag/alias + self.aliases.append(recipient) + + else: + self.logger.warning( + 'Dropped invalid tag/alias/device_token ' + '(%s) specified.' % recipient, + ) + continue + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Boxcar Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare Boxcar Object + payload = { + 'badge': 'auto', + 'alert': '%s:\r\n%s' % (title, body), + } + + if self.tags: + payload['tags'] = self.tags + + if self.aliases: + payload['aliases'] = self.aliases + + if self.device_tokens: + payload['device_tokens'] = self.device_tokens + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += '/api/push' + + self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Boxcar Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + try: + self.logger.warning( + 'Failed to send Boxcar notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + except KeyError: + self.logger.warning( + 'Failed to send Boxcar notification ' + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Boxcar ' + 'notification to %s.' % ( + self.host)) + + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py new file mode 100644 index 00000000..c0682b4a --- /dev/null +++ b/apprise/plugins/NotifyEmail.py @@ -0,0 +1,317 @@ +# -*- encoding: utf-8 -*- +# +# Email Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import re + +from datetime import datetime +from smtplib import SMTP +from smtplib import SMTPException +from socket import error as SocketError + +from email.mime.text import MIMEText + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import IS_EMAIL_RE + +# Default Non-Encryption Port +EMAIL_SMTP_PORT = 25 + +# Default Secure Port +EMAIL_SMTPS_PORT = 587 + +# Default SMTP Timeout (in seconds) +SMTP_SERVER_TIMEOUT = 30 + + +class WebBaseLogin(object): + """ + This class is just used in conjunction of the default emailers + to best formulate a login to it using the data detected + """ + # User Login must be Email Based + EMAIL = 'Email' + # User Login must UserID Based + USERID = 'UserID' + + +# To attempt to make this script stupid proof, +# if we detect an email address that is part of the +# this table, we can pre-use a lot more defaults if +# they aren't otherwise specified on the users +# input +WEBBASE_LOOKUP_TABLE = ( + # Google GMail + ( + 'Google Mail', + re.compile('^(?P[^@]+)@(?Pgmail\.com)$', re.I), + { + 'port': 587, + 'smtp_host': 'smtp.gmail.com', + 'secure': True, + 'login_type': (WebBaseLogin.EMAIL, ) + }, + ), + + # Pronto Mail + ( + 'Pronto Mail', + re.compile('^(?P[^@]+)@(?Pprontomail\.com)$', re.I), + { + 'port': 465, + 'smtp_host': 'secure.emailsrvr.com', + 'secure': True, + 'login_type': (WebBaseLogin.EMAIL, ) + }, + ), + + # Microsoft Hotmail + ( + 'Microsoft Hotmail', + re.compile('^(?P[^@]+)@(?P(hotmail|live)\.com)$', re.I), + { + 'port': 587, + 'smtp_host': 'smtp.live.com', + 'secure': True, + 'login_type': (WebBaseLogin.EMAIL, ) + }, + ), + + # Yahoo Mail + ( + 'Yahoo Mail', + re.compile('^(?P[^@]+)@(?Pyahoo\.(ca|com))$', re.I), + { + 'port': 465, + 'smtp_host': 'smtp.mail.yahoo.com', + 'secure': True, + 'login_type': (WebBaseLogin.EMAIL, ) + }, + ), + + # Catch All + ( + 'Custom', + re.compile('^(?P[^@]+)@(?P.+)$', re.I), + { + # Setting smtp_host to None is a way of + # auto-detecting it based on other parameters + # specified. There is no reason to ever modify + # this Catch All + 'smtp_host': None, + }, + ), +) + +# Mail Prefix Servers (TODO) +MAIL_SERVER_PREFIXES = ( + 'smtp', 'mail', 'smtps', 'outgoing' +) + + +class NotifyEmail(NotifyBase): + """ + A wrapper to Email Notifications + + """ + + # The default simple (insecure) protocol + PROTOCOL = 'mailto' + + # The default secure protocol + SECURE_PROTOCOL = 'mailtos' + + def __init__(self, to, notify_format, **kwargs): + """ + Initialize Email Object + """ + super(NotifyEmail, self).__init__( + title_maxlen=250, body_maxlen=32768, + notify_format=notify_format, + **kwargs) + + # Store To Addr + self.to_addr = to + + # Handle SMTP vs SMTPS (Secure vs UnSecure) + if not self.port: + if self.secure: + self.port = EMAIL_SMTPS_PORT + else: + self.port = EMAIL_SMTP_PORT + + # Email SMTP Server Timeout + try: + self.timeout = int(kwargs.get('timeout', SMTP_SERVER_TIMEOUT)) + except (ValueError, TypeError): + self.timeout = SMTP_SERVER_TIMEOUT + + # Now we want to construct the To and From email + # addresses from the URL provided + self.from_name = kwargs.get('name', 'NZB Notification') + self.from_addr = kwargs.get('from', None) + if not self.from_addr: + # Keep trying to be clever and make it equal to the to address + self.from_addr = self.to_addr + + if not isinstance(self.to_addr, basestring): + raise TypeError('No valid ~To~ email address specified.') + + if not IS_EMAIL_RE.match(self.to_addr): + raise TypeError('Invalid ~To~ email format: %s' % self.to_addr) + + if not isinstance(self.from_addr, basestring): + raise TypeError('No valid ~From~ email address specified.') + + match = IS_EMAIL_RE.match(self.from_addr) + if not match: + # Parse Source domain based on from_addr + raise TypeError('Invalid ~From~ email format: %s' % self.to_addr) + + # Now detect the SMTP Server + self.smtp_host = kwargs.get('smtp_host', None) + + # Apply any defaults based on certain known configurations + self.NotifyEmailDefaults() + + # Using the match, we want to extract the user id and domain + return + + def NotifyEmailDefaults(self): + """ + A function that prefills defaults based on the email + it was provided. + """ + + if self.smtp_host: + # SMTP Server was explicitly specified, therefore it + # is assumed the caller knows what he's doing and + # is intentionally over-riding any smarts to be + # applied + return + + for i in range(len(WEBBASE_LOOKUP_TABLE)): + self.logger.debug('Scanning %s against %s' % ( + self.to_addr, WEBBASE_LOOKUP_TABLE[i][0] + )) + match = WEBBASE_LOOKUP_TABLE[i][1].match(self.to_addr) + if match: + self.logger.info( + 'Applying %s Defaults' % + WEBBASE_LOOKUP_TABLE[i][0], + ) + self.port = WEBBASE_LOOKUP_TABLE[i][2]\ + .get('port', self.port) + self.secure = WEBBASE_LOOKUP_TABLE[i][2]\ + .get('secure', self.secure) + + self.smtp_host = WEBBASE_LOOKUP_TABLE[i][2]\ + .get('smtp_host', self.smtp_host) + + if self.smtp_host is None: + # Detect Server if possible + self.smtp_host = re.split('[\s@]+', self.from_addr)[-1] + + # Adjust email login based on the defined + # usertype + login_type = WEBBASE_LOOKUP_TABLE[i][2]\ + .get('login_type', []) + + if IS_EMAIL_RE.match(self.user) and \ + WebBaseLogin.EMAIL not in login_type: + # Email specified but login type + # not supported; switch it to user id + self.user = match.group('id') + + elif WebBaseLogin.USERID not in login_type: + # user specified but login type + # not supported; switch it to email + self.user = '%s@%s' % (self.user, self.host) + + break + + def _notify(self, title, body, **kwargs): + """ + Perform Email Notification + """ + + self.logger.debug('Email From: %s <%s>' % ( + self.from_addr, self.from_name)) + self.logger.debug('Email To: %s' % (self.to_addr)) + self.logger.debug('Login ID: %s' % (self.user)) + self.logger.debug('Delivery: %s:%d' % (self.smtp_host, self.port)) + + # Prepare Email Message + if self.notify_format == NotifyFormat.HTML: + email = MIMEText(body, 'html') + email['Content-Type'] = 'text/html' + else: + email = MIMEText(body, 'text') + email['Content-Type'] = 'text/plain' + + email['Subject'] = title + email['From'] = '%s <%s>' % (self.from_name, self.from_addr) + email['To'] = self.to_addr + email['Date'] = datetime.utcnow()\ + .strftime("%a, %d %b %Y %H:%M:%S +0000") + email['X-Application'] = self.app_id + + try: + self.logger.debug('Connecting to remote SMTP server...') + socket = SMTP( + self.smtp_host, + self.port, + None, + timeout=self.timeout, + ) + + if self.secure: + # Handle Secure Connections + self.logger.debug('Securing connection with TLS...') + socket.starttls() + + if self.user and self.password: + # Apply Login credetials + self.logger.debug('Applying user credentials...') + socket.login(self.user, self.password) + + # Send the email + socket.sendmail(self.from_addr, self.to_addr, email.as_string()) + + self.logger.info('Sent Email notification to "%s".' % ( + self.to_addr, + )) + + except (SocketError, SMTPException), e: + self.logger.warning( + 'A Connection error occured sending Email ' + 'notification to %s.' % self.smtp_host) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return False + + try: + socket.quit() + except: + # no problem + pass + + return True diff --git a/apprise/plugins/NotifyFaast.py b/apprise/plugins/NotifyFaast.py new file mode 100644 index 00000000..9114aa65 --- /dev/null +++ b/apprise/plugins/NotifyFaast.py @@ -0,0 +1,123 @@ +# -*- encoding: utf-8 -*- +# +# Faast Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import requests + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Faast uses the http protocol with JSON requests +FAAST_URL = 'https://www.appnotifications.com/account/notifications.json' + +# Image Support (72x72) +FAAST_IMAGE_XY = NotifyImageSize.XY_72 + + +class NotifyFaast(NotifyBase): + """ + A wrapper for Faast Notifications + """ + + # The default protocol (this is secure for faast) + PROTOCOL = 'faast' + + # The default secure protocol + SECURE_PROTOCOL = 'faast' + + def __init__(self, authtoken, **kwargs): + """ + Initialize Faast Object + """ + super(NotifyFaast, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=FAAST_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + self.authtoken = authtoken + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Faast Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'multipart/form-data' + } + + # prepare JSON Object + payload = { + 'user_credentials': self.authtoken, + 'title': title, + 'message': body, + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + payload['icon_url'] = image_url + + self.logger.debug('Faast POST URL: %s (cert_verify=%r)' % ( + FAAST_URL, self.verify_certificate, + )) + self.logger.debug('Faast Payload: %s' % str(payload)) + try: + r = requests.post( + FAAST_URL, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Faast notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Faast notification ' + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + else: + self.logger.info('Sent Faast notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Faast notification.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py new file mode 100644 index 00000000..d20f5549 --- /dev/null +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -0,0 +1,193 @@ +# -*- encoding: utf-8 -*- +# +# Growl Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from ..NotifyBase import NotifyBase +from ..NotifyBase import NotifyFormat +from ..NotifyBase import NotifyImageSize + +from .gntp.notifier import GrowlNotifier +from .gntp.errors import NetworkError as GrowlNetworkError +from .gntp.errors import AuthError as GrowlAuthenticationError + +# Default Growl Port +GROWL_UDP_PORT = 23053 + +# Image Support (72x72) +GROWL_IMAGE_XY = NotifyImageSize.XY_72 + + +# Priorities +class GrowlPriority(object): + VERY_LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +GROWL_PRIORITIES = ( + GrowlPriority.VERY_LOW, + GrowlPriority.MODERATE, + GrowlPriority.NORMAL, + GrowlPriority.HIGH, + GrowlPriority.EMERGENCY, +) + +GROWL_NOTIFICATION_TYPE = "New Messages" + + +class NotifyGrowl(NotifyBase): + """ + A wrapper to Growl Notifications + + """ + + # The default protocol + PROTOCOL = 'growl' + + # The default secure protocol + SECURE_PROTOCOL = 'growl' + + def __init__(self, priority=GrowlPriority.NORMAL, version=2, **kwargs): + """ + Initialize Growl Object + """ + super(NotifyGrowl, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=GROWL_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + # A Global flag that tracks registration + self.is_registered = False + + if not self.port: + self.port = GROWL_UDP_PORT + + # The Priority of the message + if priority not in GROWL_PRIORITIES: + self.priority = GrowlPriority.NORMAL + else: + self.priority = priority + + # Always default the sticky flag to False + self.sticky = False + + # Store Version + self.version = version + + payload = { + 'applicationName': self.app_id, + 'notifications': [GROWL_NOTIFICATION_TYPE, ], + 'defaultNotifications': [GROWL_NOTIFICATION_TYPE, ], + 'hostname': self.host, + 'port': self.port, + } + + if self.password is not None: + payload['password'] = self.password + + self.logger.debug('Growl Registration Payload: %s' % str(payload)) + self.growl = GrowlNotifier(**payload) + + try: + self.growl.register() + # Toggle our flag + self.is_registered = True + self.logger.debug( + 'Growl server registration completed successfully.' + ) + + except GrowlNetworkError: + self.logger.warning( + 'A network error occured sending Growl ' + 'notification to %s.' % self.host) + return + + except GrowlAuthenticationError: + self.logger.warning( + 'An authentication error occured sending Growl ' + 'notification to %s.' % self.host) + return + + return + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Growl Notification + """ + + if not self.is_registered: + # We can't do anything + return None + + icon = None + if self.include_image: + if self.version >= 2: + # URL Based + icon = self.image_url(notify_type) + else: + # Raw + icon = self.image_raw(notify_type) + + payload = { + 'noteType': GROWL_NOTIFICATION_TYPE, + 'title': title, + 'description': body, + 'icon': icon is not None, + 'sticky': False, + 'priority': self.priority, + } + self.logger.debug('Growl Payload: %s' % str(payload)) + + # Update icon of payload to be raw data + payload['icon'] = icon + + try: + response = self.growl.notify(**payload) + if not isinstance(response, bool): + self.logger.warning( + 'Growl notification failed to send with response: %s' % + str(response), + ) + + else: + self.logger.debug( + 'Growl notification sent successfully.' + ) + + except GrowlNetworkError as e: + # Since Growl servers listen for UDP broadcasts, + # it's possible that you will never get to this part + # of the code since there is no acknowledgement as to + # whether it accepted what was sent to it or not. + + # however, if the host/server is unavailable, you will + # get to this point of the code. + self.logger.warning( + 'A Connection error occured sending Growl ' + 'notification to %s.' % self.host) + self.logger.debug('Growl Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyGrowl/__init__.py b/apprise/plugins/NotifyGrowl/__init__.py new file mode 100644 index 00000000..32288f3a --- /dev/null +++ b/apprise/plugins/NotifyGrowl/__init__.py @@ -0,0 +1,6 @@ +# -*- encoding: utf-8 -*- +from . import NotifyGrowl + +__all__ = [ + 'NotifyGrowl', +] diff --git a/apprise/plugins/NotifyGrowl/gntp/__init__.py b/apprise/plugins/NotifyGrowl/gntp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apprise/plugins/NotifyGrowl/gntp/cli.py b/apprise/plugins/NotifyGrowl/gntp/cli.py new file mode 100644 index 00000000..0dc61d0a --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/cli.py @@ -0,0 +1,141 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +import logging +import os +import sys +from optparse import OptionParser, OptionGroup + +from .notifier import GrowlNotifier +from .shim import RawConfigParser +from .version import __version__ + +DEFAULT_CONFIG = os.path.expanduser('~/.gntp') + +config = RawConfigParser({ + 'hostname': 'localhost', + 'password': None, + 'port': 23053, +}) +config.read([DEFAULT_CONFIG]) +if not config.has_section('gntp'): + config.add_section('gntp') + + +class ClientParser(OptionParser): + def __init__(self): + OptionParser.__init__(self, version="%%prog %s" % __version__) + + group = OptionGroup(self, "Network Options") + group.add_option("-H", "--host", + dest="host", default=config.get('gntp', 'hostname'), + help="Specify a hostname to which to send a remote notification. [%default]") + group.add_option("--port", + dest="port", default=config.getint('gntp', 'port'), type="int", + help="port to listen on [%default]") + group.add_option("-P", "--password", + dest='password', default=config.get('gntp', 'password'), + help="Network password") + self.add_option_group(group) + + group = OptionGroup(self, "Notification Options") + group.add_option("-n", "--name", + dest="app", default='Python GNTP Test Client', + help="Set the name of the application [%default]") + group.add_option("-s", "--sticky", + dest='sticky', default=False, action="store_true", + help="Make the notification sticky [%default]") + group.add_option("--image", + dest="icon", default=None, + help="Icon for notification (URL or /path/to/file)") + group.add_option("-m", "--message", + dest="message", default=None, + help="Sets the message instead of using stdin") + group.add_option("-p", "--priority", + dest="priority", default=0, type="int", + help="-2 to 2 [%default]") + group.add_option("-d", "--identifier", + dest="identifier", + help="Identifier for coalescing") + group.add_option("-t", "--title", + dest="title", default=None, + help="Set the title of the notification [%default]") + group.add_option("-N", "--notification", + dest="name", default='Notification', + help="Set the notification name [%default]") + group.add_option("--callback", + dest="callback", + help="URL callback") + self.add_option_group(group) + + # Extra Options + self.add_option('-v', '--verbose', + dest='verbose', default=0, action='count', + help="Verbosity levels") + + def parse_args(self, args=None, values=None): + values, args = OptionParser.parse_args(self, args, values) + + if values.message is None: + print('Enter a message followed by Ctrl-D') + try: + message = sys.stdin.read() + except KeyboardInterrupt: + exit() + else: + message = values.message + + if values.title is None: + values.title = ' '.join(args) + + # If we still have an empty title, use the + # first bit of the message as the title + if values.title == '': + values.title = message[:20] + + values.verbose = logging.WARNING - values.verbose * 10 + + return values, message + + +def main(): + (options, message) = ClientParser().parse_args() + logging.basicConfig(level=options.verbose) + if not os.path.exists(DEFAULT_CONFIG): + logging.info('No config read found at %s', DEFAULT_CONFIG) + + growl = GrowlNotifier( + applicationName=options.app, + notifications=[options.name], + defaultNotifications=[options.name], + hostname=options.host, + password=options.password, + port=options.port, + ) + result = growl.register() + if result is not True: + exit(result) + + # This would likely be better placed within the growl notifier + # class but until I make _checkIcon smarter this is "easier" + if options.icon is not None and not options.icon.startswith('http'): + logging.info('Loading image %s', options.icon) + f = open(options.icon) + options.icon = f.read() + f.close() + + result = growl.notify( + noteType=options.name, + title=options.title, + description=message, + icon=options.icon, + sticky=options.sticky, + priority=options.priority, + callback=options.callback, + identifier=options.identifier, + ) + if result is not True: + exit(result) + +if __name__ == "__main__": + main() diff --git a/apprise/plugins/NotifyGrowl/gntp/config.py b/apprise/plugins/NotifyGrowl/gntp/config.py new file mode 100644 index 00000000..e293afba --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/config.py @@ -0,0 +1,77 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +The gntp.config module is provided as an extended GrowlNotifier object that takes +advantage of the ConfigParser module to allow us to setup some default values +(such as hostname, password, and port) in a more global way to be shared among +programs using gntp +""" +import logging +import os + +from . import gntp.notifier +from . import gntp.shim + +__all__ = [ + 'mini', + 'GrowlNotifier' +] + +logger = logging.getLogger(__name__) + + +class GrowlNotifier(gntp.notifier.GrowlNotifier): + """ + ConfigParser enhanced GrowlNotifier object + + For right now, we are only interested in letting users overide certain + values from ~/.gntp + + :: + + [gntp] + hostname = ? + password = ? + port = ? + """ + def __init__(self, *args, **kwargs): + config = gntp.shim.RawConfigParser({ + 'hostname': kwargs.get('hostname', 'localhost'), + 'password': kwargs.get('password'), + 'port': kwargs.get('port', 23053), + }) + + config.read([os.path.expanduser('~/.gntp')]) + + # If the file does not exist, then there will be no gntp section defined + # and the config.get() lines below will get confused. Since we are not + # saving the config, it should be safe to just add it here so the + # code below doesn't complain + if not config.has_section('gntp'): + logger.info('Error reading ~/.gntp config file') + config.add_section('gntp') + + kwargs['password'] = config.get('gntp', 'password') + kwargs['hostname'] = config.get('gntp', 'hostname') + kwargs['port'] = config.getint('gntp', 'port') + + super(GrowlNotifier, self).__init__(*args, **kwargs) + + +def mini(description, **kwargs): + """Single notification function + + Simple notification function in one line. Has only one required parameter + and attempts to use reasonable defaults for everything else + :param string description: Notification message + """ + kwargs['notifierFactory'] = GrowlNotifier + gntp.notifier.mini(description, **kwargs) + + +if __name__ == '__main__': + # If we're running this module directly we're likely running it as a test + # so extra debugging is useful + logging.basicConfig(level=logging.INFO) + mini('Testing mini notification') diff --git a/apprise/plugins/NotifyGrowl/gntp/core.py b/apprise/plugins/NotifyGrowl/gntp/core.py new file mode 100644 index 00000000..60534f2d --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/core.py @@ -0,0 +1,511 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +import hashlib +import re +import time + +from . import shim +from . import errors as errors + +__all__ = [ + 'GNTPRegister', + 'GNTPNotice', + 'GNTPSubscribe', + 'GNTPOK', + 'GNTPError', + 'parse_gntp', +] + +#GNTP/ [:][ :.] +GNTP_INFO_LINE = re.compile( + 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)' + + ' (?P[A-Z0-9]+(:(?P[A-F0-9]+))?) ?' + + '((?P[A-Z0-9]+):(?P[A-F0-9]+).(?P[A-F0-9]+))?\r\n', + re.IGNORECASE +) + +GNTP_INFO_LINE_SHORT = re.compile( + 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)', + re.IGNORECASE +) + +GNTP_HEADER = re.compile('([\w-]+):(.+)') + +GNTP_EOL = shim.b('\r\n') +GNTP_SEP = shim.b(': ') + + +class _GNTPBuffer(shim.StringIO): + """GNTP Buffer class""" + def writeln(self, value=None): + if value: + self.write(shim.b(value)) + self.write(GNTP_EOL) + + def writeheader(self, key, value): + if not isinstance(value, str): + value = str(value) + self.write(shim.b(key)) + self.write(GNTP_SEP) + self.write(shim.b(value)) + self.write(GNTP_EOL) + + +class _GNTPBase(object): + """Base initilization + + :param string messagetype: GNTP Message type + :param string version: GNTP Protocol version + :param string encription: Encryption protocol + """ + def __init__(self, messagetype=None, version='1.0', encryption=None): + self.info = { + 'version': version, + 'messagetype': messagetype, + 'encryptionAlgorithmID': encryption + } + self.hash_algo = { + 'MD5': hashlib.md5, + 'SHA1': hashlib.sha1, + 'SHA256': hashlib.sha256, + 'SHA512': hashlib.sha512, + } + self.headers = {} + self.resources = {} + + def __str__(self): + return self.encode() + + def _parse_info(self, data): + """Parse the first line of a GNTP message to get security and other info values + + :param string data: GNTP Message + :return dict: Parsed GNTP Info line + """ + + match = GNTP_INFO_LINE.match(data) + + if not match: + raise errors.ParseError('ERROR_PARSING_INFO_LINE') + + info = match.groupdict() + if info['encryptionAlgorithmID'] == 'NONE': + info['encryptionAlgorithmID'] = None + + return info + + def set_password(self, password, encryptAlgo='MD5'): + """Set a password for a GNTP Message + + :param string password: Null to clear password + :param string encryptAlgo: Supports MD5, SHA1, SHA256, SHA512 + """ + if not password: + self.info['encryptionAlgorithmID'] = None + self.info['keyHashAlgorithm'] = None + return + + self.password = shim.b(password) + self.encryptAlgo = encryptAlgo.upper() + + if not self.encryptAlgo in self.hash_algo: + raise errors.UnsupportedError('INVALID HASH "%s"' % self.encryptAlgo) + + hashfunction = self.hash_algo.get(self.encryptAlgo) + + password = password.encode('utf8') + seed = time.ctime().encode('utf8') + salt = hashfunction(seed).hexdigest() + saltHash = hashfunction(seed).digest() + keyBasis = password + saltHash + key = hashfunction(keyBasis).digest() + keyHash = hashfunction(key).hexdigest() + + self.info['keyHashAlgorithmID'] = self.encryptAlgo + self.info['keyHash'] = keyHash.upper() + self.info['salt'] = salt.upper() + + def _decode_hex(self, value): + """Helper function to decode hex string to `proper` hex string + + :param string value: Human readable hex string + :return string: Hex string + """ + result = '' + for i in range(0, len(value), 2): + tmp = int(value[i:i + 2], 16) + result += chr(tmp) + return result + + def _decode_binary(self, rawIdentifier, identifier): + rawIdentifier += '\r\n\r\n' + dataLength = int(identifier['Length']) + pointerStart = self.raw.find(rawIdentifier) + len(rawIdentifier) + pointerEnd = pointerStart + dataLength + data = self.raw[pointerStart:pointerEnd] + if not len(data) == dataLength: + raise errors.ParseError('INVALID_DATA_LENGTH Expected: %s Recieved %s' % (dataLength, len(data))) + return data + + def _validate_password(self, password): + """Validate GNTP Message against stored password""" + self.password = password + if password is None: + raise errors.AuthError('Missing password') + keyHash = self.info.get('keyHash', None) + if keyHash is None and self.password is None: + return True + if keyHash is None: + raise errors.AuthError('Invalid keyHash') + if self.password is None: + raise errors.AuthError('Missing password') + + keyHashAlgorithmID = self.info.get('keyHashAlgorithmID','MD5') + + password = self.password.encode('utf8') + saltHash = self._decode_hex(self.info['salt']) + + keyBasis = password + saltHash + self.key = self.hash_algo[keyHashAlgorithmID](keyBasis).digest() + keyHash = self.hash_algo[keyHashAlgorithmID](self.key).hexdigest() + + if not keyHash.upper() == self.info['keyHash'].upper(): + raise errors.AuthError('Invalid Hash') + return True + + def validate(self): + """Verify required headers""" + for header in self._requiredHeaders: + if not self.headers.get(header, False): + raise errors.ParseError('Missing Notification Header: ' + header) + + def _format_info(self): + """Generate info line for GNTP Message + + :return string: + """ + info = 'GNTP/%s %s' % ( + self.info.get('version'), + self.info.get('messagetype'), + ) + if self.info.get('encryptionAlgorithmID', None): + info += ' %s:%s' % ( + self.info.get('encryptionAlgorithmID'), + self.info.get('ivValue'), + ) + else: + info += ' NONE' + + if self.info.get('keyHashAlgorithmID', None): + info += ' %s:%s.%s' % ( + self.info.get('keyHashAlgorithmID'), + self.info.get('keyHash'), + self.info.get('salt') + ) + + return info + + def _parse_dict(self, data): + """Helper function to parse blocks of GNTP headers into a dictionary + + :param string data: + :return dict: Dictionary of parsed GNTP Headers + """ + d = {} + for line in data.split('\r\n'): + match = GNTP_HEADER.match(line) + if not match: + continue + + key = match.group(1).strip() + val = match.group(2).strip() + d[key] = val + return d + + def add_header(self, key, value): + self.headers[key] = value + + def add_resource(self, data): + """Add binary resource + + :param string data: Binary Data + """ + data = shim.b(data) + identifier = hashlib.md5(data).hexdigest() + self.resources[identifier] = data + return 'x-growl-resource://%s' % identifier + + def decode(self, data, password=None): + """Decode GNTP Message + + :param string data: + """ + self.password = password + self.raw = shim.u(data) + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(self.raw) + self.headers = self._parse_dict(parts[0]) + + def encode(self): + """Encode a generic GNTP Message + + :return string: GNTP Message ready to be sent. Returned as a byte string + """ + + buff = _GNTPBuffer() + + buff.writeln(self._format_info()) + + #Headers + for k, v in self.headers.items(): + buff.writeheader(k, v) + buff.writeln() + + #Resources + for resource, data in self.resources.items(): + buff.writeheader('Identifier', resource) + buff.writeheader('Length', len(data)) + buff.writeln() + buff.write(data) + buff.writeln() + buff.writeln() + + return buff.getvalue() + + +class GNTPRegister(_GNTPBase): + """Represents a GNTP Registration Command + + :param string data: (Optional) See decode() + :param string password: (Optional) Password to use while encoding/decoding messages + """ + _requiredHeaders = [ + 'Application-Name', + 'Notifications-Count' + ] + _requiredNotificationHeaders = ['Notification-Name'] + + def __init__(self, data=None, password=None): + _GNTPBase.__init__(self, 'REGISTER') + self.notifications = [] + + if data: + self.decode(data, password) + else: + self.set_password(password) + self.add_header('Application-Name', 'pygntp') + self.add_header('Notifications-Count', 0) + + def validate(self): + '''Validate required headers and validate notification headers''' + for header in self._requiredHeaders: + if not self.headers.get(header, False): + raise errors.ParseError('Missing Registration Header: ' + header) + for notice in self.notifications: + for header in self._requiredNotificationHeaders: + if not notice.get(header, False): + raise errors.ParseError('Missing Notification Header: ' + header) + + def decode(self, data, password): + """Decode existing GNTP Registration message + + :param string data: Message to decode + """ + self.raw = shim.u(data) + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(self.raw) + self._validate_password(password) + self.headers = self._parse_dict(parts[0]) + + for i, part in enumerate(parts): + if i == 0: + continue # Skip Header + if part.strip() == '': + continue + notice = self._parse_dict(part) + if notice.get('Notification-Name', False): + self.notifications.append(notice) + elif notice.get('Identifier', False): + notice['Data'] = self._decode_binary(part, notice) + #open('register.png','wblol').write(notice['Data']) + self.resources[notice.get('Identifier')] = notice + + def add_notification(self, name, enabled=True): + """Add new Notification to Registration message + + :param string name: Notification Name + :param boolean enabled: Enable this notification by default + """ + notice = {} + notice['Notification-Name'] = name + notice['Notification-Enabled'] = enabled + + self.notifications.append(notice) + self.add_header('Notifications-Count', len(self.notifications)) + + def encode(self): + """Encode a GNTP Registration Message + + :return string: Encoded GNTP Registration message. Returned as a byte string + """ + + buff = _GNTPBuffer() + + buff.writeln(self._format_info()) + + #Headers + for k, v in self.headers.items(): + buff.writeheader(k, v) + buff.writeln() + + #Notifications + if len(self.notifications) > 0: + for notice in self.notifications: + for k, v in notice.items(): + buff.writeheader(k, v) + buff.writeln() + + #Resources + for resource, data in self.resources.items(): + buff.writeheader('Identifier', resource) + buff.writeheader('Length', len(data)) + buff.writeln() + buff.write(data) + buff.writeln() + buff.writeln() + + return buff.getvalue() + + +class GNTPNotice(_GNTPBase): + """Represents a GNTP Notification Command + + :param string data: (Optional) See decode() + :param string app: (Optional) Set Application-Name + :param string name: (Optional) Set Notification-Name + :param string title: (Optional) Set Notification Title + :param string password: (Optional) Password to use while encoding/decoding messages + """ + _requiredHeaders = [ + 'Application-Name', + 'Notification-Name', + 'Notification-Title' + ] + + def __init__(self, data=None, app=None, name=None, title=None, password=None): + _GNTPBase.__init__(self, 'NOTIFY') + + if data: + self.decode(data, password) + else: + self.set_password(password) + if app: + self.add_header('Application-Name', app) + if name: + self.add_header('Notification-Name', name) + if title: + self.add_header('Notification-Title', title) + + def decode(self, data, password): + """Decode existing GNTP Notification message + + :param string data: Message to decode. + """ + self.raw = shim.u(data) + parts = self.raw.split('\r\n\r\n') + self.info = self._parse_info(self.raw) + self._validate_password(password) + self.headers = self._parse_dict(parts[0]) + + for i, part in enumerate(parts): + if i == 0: + continue # Skip Header + if part.strip() == '': + continue + notice = self._parse_dict(part) + if notice.get('Identifier', False): + notice['Data'] = self._decode_binary(part, notice) + #open('notice.png','wblol').write(notice['Data']) + self.resources[notice.get('Identifier')] = notice + + +class GNTPSubscribe(_GNTPBase): + """Represents a GNTP Subscribe Command + + :param string data: (Optional) See decode() + :param string password: (Optional) Password to use while encoding/decoding messages + """ + _requiredHeaders = [ + 'Subscriber-ID', + 'Subscriber-Name', + ] + + def __init__(self, data=None, password=None): + _GNTPBase.__init__(self, 'SUBSCRIBE') + if data: + self.decode(data, password) + else: + self.set_password(password) + + +class GNTPOK(_GNTPBase): + """Represents a GNTP OK Response + + :param string data: (Optional) See _GNTPResponse.decode() + :param string action: (Optional) Set type of action the OK Response is for + """ + _requiredHeaders = ['Response-Action'] + + def __init__(self, data=None, action=None): + _GNTPBase.__init__(self, '-OK') + if data: + self.decode(data) + if action: + self.add_header('Response-Action', action) + + +class GNTPError(_GNTPBase): + """Represents a GNTP Error response + + :param string data: (Optional) See _GNTPResponse.decode() + :param string errorcode: (Optional) Error code + :param string errordesc: (Optional) Error Description + """ + _requiredHeaders = ['Error-Code', 'Error-Description'] + + def __init__(self, data=None, errorcode=None, errordesc=None): + _GNTPBase.__init__(self, '-ERROR') + if data: + self.decode(data) + if errorcode: + self.add_header('Error-Code', errorcode) + self.add_header('Error-Description', errordesc) + + def error(self): + return (self.headers.get('Error-Code', None), + self.headers.get('Error-Description', None)) + + +def parse_gntp(data, password=None): + """Attempt to parse a message as a GNTP message + + :param string data: Message to be parsed + :param string password: Optional password to be used to verify the message + """ + data = shim.u(data) + match = GNTP_INFO_LINE_SHORT.match(data) + if not match: + raise errors.ParseError('INVALID_GNTP_INFO') + info = match.groupdict() + if info['messagetype'] == 'REGISTER': + return GNTPRegister(data, password=password) + elif info['messagetype'] == 'NOTIFY': + return GNTPNotice(data, password=password) + elif info['messagetype'] == 'SUBSCRIBE': + return GNTPSubscribe(data, password=password) + elif info['messagetype'] == '-OK': + return GNTPOK(data) + elif info['messagetype'] == '-ERROR': + return GNTPError(data) + raise errors.ParseError('INVALID_GNTP_MESSAGE') diff --git a/apprise/plugins/NotifyGrowl/gntp/errors.py b/apprise/plugins/NotifyGrowl/gntp/errors.py new file mode 100644 index 00000000..c006fd68 --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/errors.py @@ -0,0 +1,25 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +class BaseError(Exception): + pass + + +class ParseError(BaseError): + errorcode = 500 + errordesc = 'Error parsing the message' + + +class AuthError(BaseError): + errorcode = 400 + errordesc = 'Error with authorization' + + +class UnsupportedError(BaseError): + errorcode = 500 + errordesc = 'Currently unsupported by gntp.py' + + +class NetworkError(BaseError): + errorcode = 500 + errordesc = "Error connecting to growl server" diff --git a/apprise/plugins/NotifyGrowl/gntp/notifier.py b/apprise/plugins/NotifyGrowl/gntp/notifier.py new file mode 100644 index 00000000..fc659426 --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/notifier.py @@ -0,0 +1,265 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +The gntp.notifier module is provided as a simple way to send notifications +using GNTP + +.. note:: + This class is intended to mostly mirror the older Python bindings such + that you should be able to replace instances of the old bindings with + this class. + `Original Python bindings `_ + +""" +import logging +import platform +import socket +import sys + +from .version import __version__ +from . import core +from . import errors as errors +from . import shim + +__all__ = [ + 'mini', + 'GrowlNotifier', +] + +logger = logging.getLogger(__name__) + + +class GrowlNotifier(object): + """Helper class to simplfy sending Growl messages + + :param string applicationName: Sending application name + :param list notification: List of valid notifications + :param list defaultNotifications: List of notifications that should be enabled + by default + :param string applicationIcon: Icon URL + :param string hostname: Remote host + :param integer port: Remote port + """ + + passwordHash = 'MD5' + socketTimeout = 3 + + def __init__(self, applicationName='Python GNTP', notifications=[], + defaultNotifications=None, applicationIcon=None, hostname='localhost', + password=None, port=23053): + + self.applicationName = applicationName + self.notifications = list(notifications) + if defaultNotifications: + self.defaultNotifications = list(defaultNotifications) + else: + self.defaultNotifications = self.notifications + self.applicationIcon = applicationIcon + + self.password = password + self.hostname = hostname + self.port = int(port) + + def _checkIcon(self, data): + ''' + Check the icon to see if it's valid + + If it's a simple URL icon, then we return True. If it's a data icon + then we return False + ''' + logger.info('Checking icon') + return shim.u(data).startswith('http') + + def register(self): + """Send GNTP Registration + + .. warning:: + Before sending notifications to Growl, you need to have + sent a registration message at least once + """ + logger.info('Sending registration to %s:%s', self.hostname, self.port) + register = core.GNTPRegister() + register.add_header('Application-Name', self.applicationName) + for notification in self.notifications: + enabled = notification in self.defaultNotifications + register.add_notification(notification, enabled) + if self.applicationIcon: + if self._checkIcon(self.applicationIcon): + register.add_header('Application-Icon', self.applicationIcon) + else: + resource = register.add_resource(self.applicationIcon) + register.add_header('Application-Icon', resource) + if self.password: + register.set_password(self.password, self.passwordHash) + self.add_origin_info(register) + self.register_hook(register) + return self._send('register', register) + + def notify(self, noteType, title, description, icon=None, sticky=False, + priority=None, callback=None, identifier=None, custom={}): + """Send a GNTP notifications + + .. warning:: + Must have registered with growl beforehand or messages will be ignored + + :param string noteType: One of the notification names registered earlier + :param string title: Notification title (usually displayed on the notification) + :param string description: The main content of the notification + :param string icon: Icon URL path + :param boolean sticky: Sticky notification + :param integer priority: Message priority level from -2 to 2 + :param string callback: URL callback + :param dict custom: Custom attributes. Key names should be prefixed with X- + according to the spec but this is not enforced by this class + + .. warning:: + For now, only URL callbacks are supported. In the future, the + callback argument will also support a function + """ + logger.info('Sending notification [%s] to %s:%s', noteType, self.hostname, self.port) + assert noteType in self.notifications + notice = core.GNTPNotice() + notice.add_header('Application-Name', self.applicationName) + notice.add_header('Notification-Name', noteType) + notice.add_header('Notification-Title', title) + if self.password: + notice.set_password(self.password, self.passwordHash) + if sticky: + notice.add_header('Notification-Sticky', sticky) + if priority: + notice.add_header('Notification-Priority', priority) + if icon: + if self._checkIcon(icon): + notice.add_header('Notification-Icon', icon) + else: + resource = notice.add_resource(icon) + notice.add_header('Notification-Icon', resource) + + if description: + notice.add_header('Notification-Text', description) + if callback: + notice.add_header('Notification-Callback-Target', callback) + if identifier: + notice.add_header('Notification-Coalescing-ID', identifier) + + for key in custom: + notice.add_header(key, custom[key]) + + self.add_origin_info(notice) + self.notify_hook(notice) + + return self._send('notify', notice) + + def subscribe(self, id, name, port): + """Send a Subscribe request to a remote machine""" + sub = core.GNTPSubscribe() + sub.add_header('Subscriber-ID', id) + sub.add_header('Subscriber-Name', name) + sub.add_header('Subscriber-Port', port) + if self.password: + sub.set_password(self.password, self.passwordHash) + + self.add_origin_info(sub) + self.subscribe_hook(sub) + + return self._send('subscribe', sub) + + def add_origin_info(self, packet): + """Add optional Origin headers to message""" + packet.add_header('Origin-Machine-Name', platform.node()) + packet.add_header('Origin-Software-Name', 'gntp.py') + packet.add_header('Origin-Software-Version', __version__) + packet.add_header('Origin-Platform-Name', platform.system()) + packet.add_header('Origin-Platform-Version', platform.platform()) + + def register_hook(self, packet): + pass + + def notify_hook(self, packet): + pass + + def subscribe_hook(self, packet): + pass + + def _send(self, messagetype, packet): + """Send the GNTP Packet""" + + packet.validate() + data = packet.encode() + + logger.debug('To : %s:%s <%s>\n%s', self.hostname, self.port, packet.__class__, data) + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(self.socketTimeout) + try: + s.connect((self.hostname, self.port)) + s.send(data) + recv_data = s.recv(1024) + while not recv_data.endswith(shim.b("\r\n\r\n")): + recv_data += s.recv(1024) + except socket.error: + # Python2.5 and Python3 compatibile exception + exc = sys.exc_info()[1] + raise errors.NetworkError(exc) + + response = core.parse_gntp(recv_data) + s.close() + + logger.debug('From : %s:%s <%s>\n%s', self.hostname, self.port, response.__class__, response) + + if type(response) == core.GNTPOK: + return True + logger.error('Invalid response: %s', response.error()) + return response.error() + + +def mini(description, applicationName='PythonMini', noteType="Message", + title="Mini Message", applicationIcon=None, hostname='localhost', + password=None, port=23053, sticky=False, priority=None, + callback=None, notificationIcon=None, identifier=None, + notifierFactory=GrowlNotifier): + """Single notification function + + Simple notification function in one line. Has only one required parameter + and attempts to use reasonable defaults for everything else + :param string description: Notification message + + .. warning:: + For now, only URL callbacks are supported. In the future, the + callback argument will also support a function + """ + try: + growl = notifierFactory( + applicationName=applicationName, + notifications=[noteType], + defaultNotifications=[noteType], + applicationIcon=applicationIcon, + hostname=hostname, + password=password, + port=port, + ) + result = growl.register() + if result is not True: + return result + + return growl.notify( + noteType=noteType, + title=title, + description=description, + icon=notificationIcon, + sticky=sticky, + priority=priority, + callback=callback, + identifier=identifier, + ) + except Exception: + # We want the "mini" function to be simple and swallow Exceptions + # in order to be less invasive + logger.exception("Growl error") + +if __name__ == '__main__': + # If we're running this module directly we're likely running it as a test + # so extra debugging is useful + logging.basicConfig(level=logging.INFO) + mini('Testing mini notification') diff --git a/apprise/plugins/NotifyGrowl/gntp/shim.py b/apprise/plugins/NotifyGrowl/gntp/shim.py new file mode 100644 index 00000000..3a387828 --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/shim.py @@ -0,0 +1,45 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +Python2.5 and Python3.3 compatibility shim + +Heavily inspirted by the "six" library. +https://pypi.python.org/pypi/six +""" + +import sys + +PY3 = sys.version_info[0] == 3 + +if PY3: + def b(s): + if isinstance(s, bytes): + return s + return s.encode('utf8', 'replace') + + def u(s): + if isinstance(s, bytes): + return s.decode('utf8', 'replace') + return s + + from io import BytesIO as StringIO + from configparser import RawConfigParser +else: + def b(s): + if isinstance(s, unicode): + return s.encode('utf8', 'replace') + return s + + def u(s): + if isinstance(s, unicode): + return s + if isinstance(s, int): + s = str(s) + return unicode(s, "utf8", "replace") + + from StringIO import StringIO + from ConfigParser import RawConfigParser + +b.__doc__ = "Ensure we have a byte string" +u.__doc__ = "Ensure we have a unicode string" diff --git a/apprise/plugins/NotifyGrowl/gntp/version.py b/apprise/plugins/NotifyGrowl/gntp/version.py new file mode 100644 index 00000000..2166aaca --- /dev/null +++ b/apprise/plugins/NotifyGrowl/gntp/version.py @@ -0,0 +1,4 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +__version__ = '1.0.2' diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py new file mode 100644 index 00000000..7a91c4a8 --- /dev/null +++ b/apprise/plugins/NotifyJSON.py @@ -0,0 +1,136 @@ +# -*- encoding: utf-8 -*- +# +# JSON Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Image Support (128x128) +JSON_IMAGE_XY = NotifyImageSize.XY_128 + + +class NotifyJSON(NotifyBase): + """ + A wrapper for JSON Notifications + """ + + # The default protocol + PROTOCOL = 'json' + + # The default secure protocol + SECURE_PROTOCOL = 'jsons' + + def __init__(self, **kwargs): + """ + Initialize JSON Object + """ + super(NotifyJSON, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=JSON_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, basestring): + self.fullpath = '/' + + return + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform JSON Notification + """ + + # prepare JSON Object + payload = { + # Version: Major.Minor, Major is only updated if the entire + # schema is changed. If just adding new items (or removing + # old ones, only increment the Minor! + 'version': '1.0', + 'title': title, + 'message': body, + 'type': notify_type, + } + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('JSON POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('JSON Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + try: + self.logger.warning( + 'Failed to send JSON notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send JSON notification ' + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending JSON ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py new file mode 100644 index 00000000..a623d23f --- /dev/null +++ b/apprise/plugins/NotifyJoin.py @@ -0,0 +1,212 @@ +# -*- encoding: utf-8 -*- +# +# Join Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +# Join URL: http://joaoapps.com/join/ +# To use this plugin, you need to first access (make sure your browser allows +# popups): https://joinjoaomgcd.appspot.com/ +# +# To register you just need to allow it to connect to your Google Profile but +# the good news is it doesn't ask for anything too personal. +# +# You can download the app for your phone here: +# https://play.google.com/store/apps/details?id=com.joaomgcd.join + +import requests +import re + +from urllib import urlencode + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP +from .NotifyBase import NotifyImageSize + +# Join uses the http protocol with JSON requests +JOIN_URL = 'https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush' + +# Token required as part of the API request +VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{32}') + +# Default User +JOIN_DEFAULT_USER = 'apprise' + +# Extend HTTP Error Messages +JOIN_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 401: 'Unauthorized - Invalid Token.', +}.items()) + +# Used to break path apart into list of devices +DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +# Used to detect a device +IS_DEVICE_RE = re.compile(r'([A-Za-z0-9]{32})') + +# Used to detect a device +IS_GROUP_RE = re.compile( + r'(group\.)?(?P(all|android|chrome|windows10|phone|tablet|pc))', + re.IGNORECASE, +) + +# Image Support (72x72) +JOIN_IMAGE_XY = NotifyImageSize.XY_72 + + +class NotifyJoin(NotifyBase): + """ + A wrapper for Join Notifications + """ + + # The default protocol + PROTOCOL = 'join' + + # The default secure protocol + SECURE_PROTOCOL = 'join' + + def __init__(self, apikey, devices, **kwargs): + """ + Initialize Join Object + """ + super(NotifyJoin, self).__init__( + title_maxlen=250, body_maxlen=1000, + image_size=JOIN_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if not VALIDATE_APIKEY.match(apikey.strip()): + self.logger.warning( + 'The first API Token specified (%s) is invalid.' % apikey, + ) + raise TypeError( + 'The first API Token specified (%s) is invalid.' % apikey, + ) + + # The token associated with the account + self.apikey = apikey.strip() + + if isinstance(devices, basestring): + self.devices = filter(bool, DEVICE_LIST_DELIM.split( + devices, + )) + elif isinstance(devices, (tuple, list)): + self.devices = devices + else: + self.devices = list() + + if len(self.devices) == 0: + self.logger.warning('No device(s) were specified.') + raise TypeError('No device(s) were specified.') + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Join Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # error tracking (used for function return) + has_error = False + + # Create a copy of the devices list + devices = list(self.devices) + while len(devices): + device = devices.pop(0) + group_re = IS_GROUP_RE.match(device) + if group_re: + device = 'group.%s' % group_re.group('name').lower() + + elif not IS_DEVICE_RE.match(device): + self.logger.warning( + "The specified device '%s' is invalid; skipping." % ( + device, + ) + ) + continue + + url_args = { + 'apikey': self.apikey, + 'deviceId': device, + 'title': title, + 'text': body, + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + url_args['icon'] = image_url + + # prepare payload + payload = { + } + + # Prepare the URL + url = '%s?%s' % (JOIN_URL, urlencode(url_args)) + + self.logger.debug('Join POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Join Payload: %s' % str(payload)) + + try: + r = requests.post( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Join:%s ' + 'notification: %s (error=%s).' % ( + device, + JOIN_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Join:%s ' + 'notification (error=%s).' % ( + device, + r.status_code)) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Join:%s ' + 'notification.' % device + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(devices): + # Prevent thrashing requests + self.throttle() + + return has_error diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py new file mode 100644 index 00000000..d76667b1 --- /dev/null +++ b/apprise/plugins/NotifyMatterMost.py @@ -0,0 +1,172 @@ +# -*- encoding: utf-8 -*- +# +# MatterMost Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP +from .NotifyBase import NOTIFY_APPLICATION_ID +import re + +# Some Reference Locations: +# - https://docs.mattermost.com/developer/webhooks-incoming.html +# - https://docs.mattermost.com/administration/config-settings.html + +# Used to validate Authorization Token +VALIDATE_AUTHTOKEN = re.compile(r'[A-Za-z0-9]{24,32}') + +# Image Support (72x72) +MATTERMOST_IMAGE_XY = NotifyImageSize.XY_72 + +# MATTERMOST uses the http protocol with JSON requests +MATTERMOST_PORT = 8065 + + +class NotifyMatterMost(NotifyBase): + """ + A wrapper for MatterMost Notifications + """ + + # The default protocol + PROTOCOL = 'mmost' + + # The default secure protocol + SECURE_PROTOCOL = 'mmosts' + + def __init__(self, authtoken, channel=None, **kwargs): + """ + Initialize MatterMost Object + """ + super(NotifyMatterMost, self).__init__( + title_maxlen=250, body_maxlen=4000, + image_size=MATTERMOST_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if self.secure: + self.schema = 'https' + else: + self.schema = 'http' + + # Our API Key + self.authtoken = authtoken + + # Validate authtoken + if not authtoken: + self.logger.warning( + 'Missing MatterMost Authorization Token.' + ) + raise TypeError( + 'Missing MatterMost Authorization Token.' + ) + + if not VALIDATE_AUTHTOKEN.match(authtoken): + self.logger.warning( + 'Invalid MatterMost Authorization Token Specified.' + ) + raise TypeError( + 'Invalid MatterMost Authorization Token Specified.' + ) + + # A Channel (optional) + self.channel = channel + + if not self.port: + self.port = MATTERMOST_PORT + + return + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform MatterMost Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare JSON Object + payload = { + 'text': '###### %s\n%s' % (title, body), + 'icon_url': self.image_url(notify_type), + } + + if self.user: + payload['username'] = self.user + + else: + payload['username'] = NOTIFY_APPLICATION_ID + + if self.channel: + payload['channel'] = self.channel + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += '/hooks/%s' % self.authtoken + + self.logger.debug('MatterMost POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('MatterMost Payload: %s' % str(payload)) + 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 + try: + self.logger.warning( + 'Failed to send MatterMost notification:' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send MatterMost notification ' + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + else: + self.logger.info('Sent MatterMost notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending MatterMost ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyMyAndroid.py b/apprise/plugins/NotifyMyAndroid.py new file mode 100644 index 00000000..6cc04a14 --- /dev/null +++ b/apprise/plugins/NotifyMyAndroid.py @@ -0,0 +1,173 @@ +# -*- encoding: utf-8 -*- +# +# Notify My Android (NMA) Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +# Notify My Android uses the http protocol with JSON requests +NMA_URL = 'https://www.notifymyandroid.com/publicapi/notify' + +# Extend HTTP Error Messages +NMA_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 400: 'Data is wrong format, invalid length or null.', + 401: 'API Key provided is invalid', + 402: 'Maximum number of API calls per hour reached.', +}.items()) + +# Used to validate Authorization Token +VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{48}') + + +# Priorities +class NotifyMyAndroidPriority(object): + VERY_LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +NMA_PRIORITIES = ( + NotifyMyAndroidPriority.VERY_LOW, + NotifyMyAndroidPriority.MODERATE, + NotifyMyAndroidPriority.NORMAL, + NotifyMyAndroidPriority.HIGH, + NotifyMyAndroidPriority.EMERGENCY, +) + + +class NotifyMyAndroid(NotifyBase): + """ + A wrapper for Notify My Android (NMA) Notifications + """ + + # The default protocol + PROTOCOL = 'nma' + + # The default secure protocol + SECURE_PROTOCOL = 'nma' + + def __init__(self, apikey, priority=NotifyMyAndroidPriority.NORMAL, + devapikey=None, **kwargs): + """ + Initialize Notify My Android Object + """ + super(NotifyMyAndroid, self).__init__( + title_maxlen=1000, body_maxlen=10000, + notify_format=NotifyFormat.HTML, + **kwargs) + + # The Priority of the message + if priority not in NMA_PRIORITIES: + self.priority = NotifyMyAndroidPriority.NORMAL + else: + self.priority = priority + + # Validate apikey + if not VALIDATE_APIKEY.match(apikey): + self.logger.warning( + 'Invalid NMA API Key specified.' + ) + raise TypeError( + 'Invalid NMA API Key specified.' + ) + self.apikey = apikey + + if devapikey: + # Validate apikey + if not VALIDATE_APIKEY.match(devapikey): + self.logger.warning( + 'Invalid NMA DEV API Key specified.' + ) + raise TypeError( + 'Invalid NMA DEV API Key specified.' + ) + self.devapikey = devapikey + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Notify My Android Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # prepare JSON Object + payload = { + 'apikey': self.apikey, + 'application': self.app_id, + 'event': title, + 'description': body, + 'priority': self.priority, + } + + if self.notify_format == NotifyFormat.HTML: + payload['content-type'] = 'text/html' + + if self.devapikey: + payload['developerkey'] = self.devapikey + + self.logger.debug('NMA POST URL: %s (cert_verify=%r)' % ( + NMA_URL, self.verify_certificate, + )) + self.logger.debug('NMA Payload: %s' % str(payload)) + try: + r = requests.post( + NMA_URL, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send NMA notification: %s (error=%s).' % ( + NMA_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send NMA notification (error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + else: + self.logger.debug('NMA Server Response: %s.' % r.text) + self.logger.info('Sent NMA notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending NMA notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py new file mode 100644 index 00000000..2ebfab67 --- /dev/null +++ b/apprise/plugins/NotifyProwl.py @@ -0,0 +1,177 @@ +# -*- encoding: utf-8 -*- +# +# Prowl Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +# Prowl uses the http protocol with JSON requests +PROWL_URL = 'https://api.prowlapp.com/publicapi/add' + +# Used to validate API Key +VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{40}') + +# Used to validate Provider Key +VALIDATE_PROVIDERKEY = re.compile(r'[A-Za-z0-9]{40}') + + +# Priorities +class ProwlPriority(object): + VERY_LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +PROWL_PRIORITIES = ( + ProwlPriority.VERY_LOW, + ProwlPriority.MODERATE, + ProwlPriority.NORMAL, + ProwlPriority.HIGH, + ProwlPriority.EMERGENCY, +) + +# Extend HTTP Error Messages +PROWL_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 406: 'IP address has exceeded API limit', + 409: 'Request not aproved.', +}.items()) + + +class NotifyProwl(NotifyBase): + """ + A wrapper for Prowl Notifications + """ + + # The default protocol + PROTOCOL = 'prowl' + + # The default secure protocol + SECURE_PROTOCOL = 'prowl' + + def __init__(self, apikey, providerkey=None, + priority=ProwlPriority.NORMAL, + **kwargs): + """ + Initialize Prowl Object + """ + super(NotifyProwl, self).__init__( + title_maxlen=1024, body_maxlen=10000, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if priority not in PROWL_PRIORITIES: + self.priority = ProwlPriority.NORMAL + else: + self.priority = priority + + if not VALIDATE_APIKEY.match(apikey): + self.logger.warning( + 'The API key specified (%s) is invalid.' % apikey, + ) + raise TypeError( + 'The API key specified (%s) is invalid.' % apikey, + ) + + # Store the API key + self.apikey = apikey + + # Store the provider key (if specified) + if providerkey: + if not VALIDATE_PROVIDERKEY.match(providerkey): + self.logger.warning( + 'The Provider key specified (%s) ' + 'is invalid.' % providerkey) + + raise TypeError( + 'The Provider key specified (%s) ' + 'is invalid.' % providerkey) + + # Store the Provider Key + self.providerkey = providerkey + + def _notify(self, title, body, **kwargs): + """ + Perform Prowl Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-type': "application/x-www-form-urlencoded", + } + + # prepare JSON Object + payload = { + 'apikey': self.apikey, + 'application': self.app_id, + 'event': title, + 'description': body, + 'priority': self.priority, + } + + if self.providerkey: + payload['providerkey'] = self.providerkey + + self.logger.debug('Prowl POST URL: %s (cert_verify=%r)' % ( + PROWL_URL, self.verify_certificate, + )) + self.logger.debug('Prowl Payload: %s' % str(payload)) + try: + r = requests.post( + PROWL_URL, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Prowl notification: ' + '%s (error=%s).' % ( + PROWL_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Prowl 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 Prowl notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Prowl notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py new file mode 100644 index 00000000..0518c0f0 --- /dev/null +++ b/apprise/plugins/NotifyPushBullet.py @@ -0,0 +1,167 @@ +# -*- encoding: utf-8 -*- +# +# PushBullet Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP +from .NotifyBase import IS_EMAIL_RE + +# Flag used as a placeholder to sending to all devices +PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' + +# PushBullet uses the http protocol with JSON requests +PUSHBULLET_URL = 'https://api.pushbullet.com/v2/pushes' + +# Used to break apart list of potential recipients by their delimiter +# into a usable list. +RECIPIENTS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +# Extend HTTP Error Messages +PUSHBULLET_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 401: 'Unauthorized - Invalid Token.', +}.items()) + + +class NotifyPushBullet(NotifyBase): + """ + A wrapper for PushBullet Notifications + """ + + # The default protocol + PROTOCOL = 'pbul' + + # The default secure protocol + SECURE_PROTOCOL = 'pbul' + + def __init__(self, accesstoken, recipients=None, **kwargs): + """ + Initialize PushBullet Object + """ + super(NotifyPushBullet, self).__init__( + title_maxlen=250, body_maxlen=32768, + notify_format=NotifyFormat.TEXT, + **kwargs) + + self.accesstoken = accesstoken + if isinstance(recipients, basestring): + self.recipients = filter(bool, RECIPIENTS_LIST_DELIM.split( + recipients, + )) + elif isinstance(recipients, (tuple, list)): + self.recipients = recipients + else: + self.recipients = list() + + if len(self.recipients) == 0: + self.recipients = (PUSHBULLET_SEND_TO_ALL, ) + + def _notify(self, title, body, **kwargs): + """ + Perform PushBullet Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + auth = (self.accesstoken, '') + + # error tracking (used for function return) + has_error = False + + # Create a copy of the recipients list + recipients = list(self.recipients) + while len(recipients): + recipient = recipients.pop(0) + + # prepare JSON Object + payload = { + 'type': 'note', + 'title': title, + 'body': body, + } + + if recipient is PUSHBULLET_SEND_TO_ALL: + # Send to all + pass + + elif IS_EMAIL_RE.match(recipient): + payload['email'] = recipient + self.logger.debug( + "Recipient '%s' is an email address" % recipient) + + elif recipient[0] == '#': + payload['channel_tag'] = recipient[1:] + self.logger.debug("Recipient '%s' is a channel" % recipient) + + else: + payload['device_iden'] = recipient + self.logger.debug( + "Recipient '%s' is a device" % recipient) + + self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( + PUSHBULLET_URL, self.verify_certificate, + )) + self.logger.debug('PushBullet Payload: %s' % str(payload)) + try: + r = requests.post( + PUSHBULLET_URL, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send PushBullet notification: ' + '%s (error=%s).' % ( + PUSHBULLET_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send PushBullet notification ' + '(error=%s).' % r.status_code) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending PushBullet ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(recipients): + # Prevent thrashing requests + self.throttle() + + return not has_error diff --git a/apprise/plugins/NotifyPushalot.py b/apprise/plugins/NotifyPushalot.py new file mode 100644 index 00000000..299941d3 --- /dev/null +++ b/apprise/plugins/NotifyPushalot.py @@ -0,0 +1,146 @@ +# -*- encoding: utf-8 -*- +# +# Pushalot Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Pushalot uses the http protocol with JSON requests +PUSHALOT_URL = 'https://pushalot.com/api/sendmessage' + +# Image Support (72x72) +PUSHALOT_IMAGE_XY = NotifyImageSize.XY_72 + +# Extend HTTP Error Messages +PUSHALOT_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 406: 'Message throttle limit hit.', + 410: 'AuthorizedToken is no longer valid.', +}.items()) + +# Used to validate Authorization Token +VALIDATE_AUTHTOKEN = re.compile(r'[A-Za-z0-9]{32}') + + +class NotifyPushalot(NotifyBase): + """ + A wrapper for Pushalot Notifications + """ + + # The default protocol + PROTOCOL = 'palot' + + # The default secure protocol + SECURE_PROTOCOL = 'palot' + + def __init__(self, authtoken, is_important=False, **kwargs): + """ + Initialize Pushalot Object + """ + super(NotifyPushalot, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=PUSHALOT_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + # Is Important Flag + self.is_important = is_important + + self.authtoken = authtoken + # Validate authtoken + if not VALIDATE_AUTHTOKEN.match(authtoken): + self.logger.warning( + 'Invalid Pushalot Authorization Token Specified.' + ) + raise TypeError( + 'Invalid Pushalot Authorization Token Specified.' + ) + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Pushalot Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare JSON Object + payload = { + 'AuthorizationToken': self.authtoken, + 'IsImportant': self.is_important, + 'Title': title, + 'Body': body, + 'Source': self.app_id, + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + payload['Image'] = image_url + + self.logger.debug('Pushalot POST URL: %s (cert_verify=%r)' % ( + PUSHALOT_URL, self.verify_certificate, + )) + self.logger.debug('Pushalot Payload: %s' % str(payload)) + try: + r = requests.post( + PUSHALOT_URL, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Pushalot notification: ' + '%s (error=%s).' % ( + PUSHALOT_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Pushalot notification ' + '(error=%s).' % r.status_code) + + # Return; we're done + return False + + else: + self.logger.info('Sent Pushalot notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Pushalot notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyPushjet.py b/apprise/plugins/NotifyPushjet.py new file mode 100644 index 00000000..23376bbc --- /dev/null +++ b/apprise/plugins/NotifyPushjet.py @@ -0,0 +1,76 @@ +# -*- encoding: utf-8 -*- +# +# Pushjet Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from pushjet import errors +from pushjet import pushjet + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat + + +class NotifyPushjet(NotifyBase): + """ + A wrapper for Pushjet Notifications + """ + + # The default protocol + PROTOCOL = 'pjet' + + # The default secure protocol + SECURE_PROTOCOL = 'pjets' + + def __init__(self, **kwargs): + """ + Initialize Pushjet Object + """ + super(NotifyPushjet, self).__init__( + title_maxlen=250, body_maxlen=32768, + notify_format=NotifyFormat.TEXT, + **kwargs) + + def _notify(self, title, body, notify_type): + """ + Perform Pushjet Notification + """ + try: + if self.user and self.host: + server = "http://" + if self.secure: + server = "https://" + + server += self.host + if self.port: + server += ":" + str(self.port) + + api = pushjet.Api(server) + service = api.Service(secret_key=self.user) + else: + api = pushjet.Api(pushjet.DEFAULT_API_URL) + service = api.Service(secret_key=self.host) + + service.send(body, title) + + except (errors.PushjetError, ValueError) as e: + self.logger.warning('Failed to send Pushjet notification.') + self.logger.debug('Pushjet Exception: %s' % str(e)) + return False + + return True diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py new file mode 100644 index 00000000..2a7db2ce --- /dev/null +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -0,0 +1,76 @@ +# -*- encoding: utf-8 -*- +# +# Pushjet Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from .pushjet import errors +from .pushjet import pushjet + +from ..NotifyBase import NotifyBase +from ..NotifyBase import NotifyFormat + + +class NotifyPushjet(NotifyBase): + """ + A wrapper for Pushjet Notifications + """ + + # The default protocol + PROTOCOL = 'pjet' + + # The default secure protocol + SECURE_PROTOCOL = 'pjets' + + def __init__(self, **kwargs): + """ + Initialize Pushjet Object + """ + super(NotifyPushjet, self).__init__( + title_maxlen=250, body_maxlen=32768, + notify_format=NotifyFormat.TEXT, + **kwargs) + + def _notify(self, title, body, notify_type): + """ + Perform Pushjet Notification + """ + try: + if self.user and self.host: + server = "http://" + if self.secure: + server = "https://" + + server += self.host + if self.port: + server += ":" + str(self.port) + + api = pushjet.Api(server) + service = api.Service(secret_key=self.user) + else: + api = pushjet.Api(pushjet.DEFAULT_API_URL) + service = api.Service(secret_key=self.host) + + service.send(body, title) + + except (errors.PushjetError, ValueError) as e: + self.logger.warning('Failed to send Pushjet notification.') + self.logger.debug('Pushjet Exception: %s' % str(e)) + return False + + return True diff --git a/apprise/plugins/NotifyPushjet/__init__.py b/apprise/plugins/NotifyPushjet/__init__.py new file mode 100644 index 00000000..7eb52291 --- /dev/null +++ b/apprise/plugins/NotifyPushjet/__init__.py @@ -0,0 +1,6 @@ +# -*- encoding: utf-8 -*- +from . import NotifyPushjet + +__all__ = [ + 'NotifyPushjet', +] diff --git a/apprise/plugins/NotifyPushjet/pushjet/__init__.py b/apprise/plugins/NotifyPushjet/pushjet/__init__.py new file mode 100644 index 00000000..929160da --- /dev/null +++ b/apprise/plugins/NotifyPushjet/pushjet/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +"""A Python API for Pushjet. Send notifications to your phone from Python scripts!""" + +from .pushjet import Service, Device, Subscription, Message, Api +from .errors import PushjetError, AccessError, NonexistentError, SubscriptionError, RequestError, ServerError diff --git a/apprise/plugins/NotifyPushjet/pushjet/errors.py b/apprise/plugins/NotifyPushjet/pushjet/errors.py new file mode 100644 index 00000000..3fd11109 --- /dev/null +++ b/apprise/plugins/NotifyPushjet/pushjet/errors.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from requests import RequestException + +import sys +if sys.version_info[0] < 3: + # This is built into Python 3. + class ConnectionError(Exception): + pass + +class PushjetError(Exception): + """All the errors inherit from this. Therefore, ``except PushjetError`` catches all errors.""" + +class AccessError(PushjetError): + """Raised when a secret key is missing for a service method that needs one.""" + +class NonexistentError(PushjetError): + """Raised when an attempt to access a nonexistent service is made.""" + +class SubscriptionError(PushjetError): + """Raised when an attempt to subscribe to a service that's already subscribed to, + or to unsubscribe from a service that isn't subscribed to, is made.""" + +class RequestError(PushjetError, ConnectionError): + """Raised if something goes wrong in the connection to the API server. + Inherits from ``ConnectionError`` on Python 3, and can therefore be caught + with ``except ConnectionError`` there. + + :ivar requests_exception: The underlying `requests `__ + exception. Access this if you want to handle different HTTP request errors in different ways. + """ + + def __str__(self): + return "requests.{error}: {description}".format( + error=self.requests_exception.__class__.__name__, + description=str(self.requests_exception) + ) + + def __init__(self, requests_exception): + self.requests_exception = requests_exception + +class ServerError(PushjetError): + """Raised if the API server has an error while processing your request. + This getting raised means there's a bug in the server! If you manage to + track down what caused it, you can `open an issue on Pushjet's GitHub page + `__. + """ diff --git a/apprise/plugins/NotifyPushjet/pushjet/pushjet.py b/apprise/plugins/NotifyPushjet/pushjet/pushjet.py new file mode 100644 index 00000000..6a527137 --- /dev/null +++ b/apprise/plugins/NotifyPushjet/pushjet/pushjet.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import requests +from functools import partial + +from .utilities import ( + NoNoneDict, + requires_secret_key, with_api_bound, + is_valid_uuid, is_valid_public_key, is_valid_secret_key, repr_format +) +from .errors import NonexistentError, SubscriptionError, RequestError, ServerError + +import sys +if sys.version_info[0] >= 3: + from urllib.parse import urljoin + unicode_type = str +else: + from urlparse import urljoin + unicode_type = unicode + +DEFAULT_API_URL = 'https://api.pushjet.io/' + +class PushjetModel(object): + _api = None # This is filled in later. + +class Service(PushjetModel): + """A Pushjet service to send messages through. To receive messages, devices + subscribe to these. + + :param secret_key: The service's API key for write access. If provided, + :func:`~pushjet.Service.send`, :func:`~pushjet.Service.edit`, and + :func:`~pushjet.Service.delete` become available. + Either this or the public key parameter must be present. + :param public_key: The service's public API key for read access only. + Either this or the secret key parameter must be present. + + :ivar name: The name of the service. + :ivar icon_url: The URL to the service's icon. May be ``None``. + :ivar created: When the service was created, as seconds from epoch. + :ivar secret_key: The service's secret API key, or ``None`` if the service is read-only. + :ivar public_key: The service's public API key, to be used when subscribing to the service. + """ + + def __repr__(self): + return "".format(repr_format(self.name)) + + def __init__(self, secret_key=None, public_key=None): + if secret_key is None and public_key is None: + raise ValueError("Either a secret key or public key " + "must be provided.") + elif secret_key and not is_valid_secret_key(secret_key): + raise ValueError("Invalid secret key provided.") + elif public_key and not is_valid_public_key(public_key): + raise ValueError("Invalid public key provided.") + self.secret_key = unicode_type(secret_key) if secret_key else None + self.public_key = unicode_type(public_key) if public_key else None + self.refresh() + + def _request(self, endpoint, method, is_secret, params=None, data=None): + params = params or {} + if is_secret: + params['secret'] = self.secret_key + else: + params['service'] = self.public_key + return self._api._request(endpoint, method, params, data) + + @requires_secret_key + def send(self, message, title=None, link=None, importance=None): + """Send a message to the service's subscribers. + + :param message: The message body to be sent. + :param title: (optional) The message's title. Messages can be without title. + :param link: (optional) An URL to be sent with the message. + :param importance: (optional) The priority level of the message. May be + a number between 1 and 5, where 1 is least important and 5 is most. + """ + data = NoNoneDict({ + 'message': message, + 'title': title, + 'link': link, + 'level': importance + }) + self._request('message', 'POST', is_secret=True, data=data) + + @requires_secret_key + def edit(self, name=None, icon_url=None): + """Edit the service's attributes. + + :param name: (optional) A new name to give the service. + :param icon_url: (optional) A new URL to use as the service's icon URL. + Set to an empty string to remove the service's icon entirely. + """ + data = NoNoneDict({ + 'name': name, + 'icon': icon_url + }) + if not data: + return + self._request('service', 'PATCH', is_secret=True, data=data) + self.name = unicode_type(name) + self.icon_url = unicode_type(icon_url) + + @requires_secret_key + def delete(self): + """Delete the service. Irreversible.""" + self._request('service', 'DELETE', is_secret=True) + + def _update_from_data(self, data): + self.name = data['name'] + self.icon_url = data['icon'] or None + self.created = data['created'] + self.public_key = data['public'] + self.secret_key = data.get('secret', getattr(self, 'secret_key', None)) + + def refresh(self): + """Refresh the server's information, in case it could be edited from elsewhere. + + :raises: :exc:`~pushjet.NonexistentError` if the service was deleted before refreshing. + """ + key_name = 'public' + secret = False + if self.secret_key is not None: + key_name = 'secret' + secret = True + + status, response = self._request('service', 'GET', is_secret=secret) + if status == requests.codes.NOT_FOUND: + raise NonexistentError("A service with the provided {} key " + "does not exist (anymore, at least).".format(key_name)) + self._update_from_data(response['service']) + + @classmethod + def _from_data(cls, data): + # This might be a no-no, but I see little alternative if + # different constructors with different parameters are needed, + # *and* a default __init__ constructor should be present. + # This, along with the subclassing for custom API URLs, may + # very well be one of those pieces of code you look back at + # years down the line - or maybe just a couple of weeks - and say + # "what the heck was I thinking"? I assure you, though, future me. + # This was the most reasonable thing to get the API + argspecs I wanted. + obj = cls.__new__(cls) + obj._update_from_data(data) + return obj + + @classmethod + def create(cls, name, icon_url=None): + """Create a new service. + + :param name: The name of the new service. + :param icon_url: (optional) An URL to an image to be used as the service's icon. + :return: The newly-created :class:`~pushjet.Service`. + """ + data = NoNoneDict({ + 'name': name, + 'icon': icon_url + }) + _, response = cls._api._request('service', 'POST', data=data) + return cls._from_data(response['service']) + +class Device(PushjetModel): + """The "receiver" for messages. Subscribes to services and receives any + messages they send. + + :param uuid: The device's unique ID as a UUID. Does not need to be registered + before using it. A UUID can be generated with ``uuid.uuid4()``, for example. + :ivar uuid: The UUID the device was initialized with. + """ + + def __repr__(self): + return "".format(self.uuid) + + def __init__(self, uuid): + uuid = unicode_type(uuid) + if not is_valid_uuid(uuid): + raise ValueError("Invalid UUID provided. Try uuid.uuid4().") + self.uuid = unicode_type(uuid) + + def _request(self, endpoint, method, params=None, data=None): + params = (params or {}) + params['uuid'] = self.uuid + return self._api._request(endpoint, method, params, data) + + def subscribe(self, service): + """Subscribe the device to a service. + + :param service: The service to subscribe to. May be a public key or a :class:`~pushjet.Service`. + :return: The :class:`~pushjet.Service` subscribed to. + + :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. + :raises: :exc:`~pushjet.SubscriptionError` if the provided service is already subscribed to. + """ + data = {} + data['service'] = service.public_key if isinstance(service, Service) else service + status, response = self._request('subscription', 'POST', data=data) + if status == requests.codes.CONFLICT: + raise SubscriptionError("The device is already subscribed to that service.") + elif status == requests.codes.NOT_FOUND: + raise NonexistentError("A service with the provided public key " + "does not exist (anymore, at least).") + return self._api.Service._from_data(response['service']) + + def unsubscribe(self, service): + """Unsubscribe the device from a service. + + :param service: The service to unsubscribe from. May be a public key or a :class:`~pushjet.Service`. + :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. + :raises: :exc:`~pushjet.SubscriptionError` if the provided service isn't subscribed to. + """ + data = {} + data['service'] = service.public_key if isinstance(service, Service) else service + status, _ = self._request('subscription', 'DELETE', data=data) + if status == requests.codes.CONFLICT: + raise SubscriptionError("The device is not subscribed to that service.") + elif status == requests.codes.NOT_FOUND: + raise NonexistentError("A service with the provided public key " + "does not exist (anymore, at least).") + + def get_subscriptions(self): + """Get all the subscriptions the device has. + + :return: A list of :class:`~pushjet.Subscription`\ s. + """ + _, response = self._request('subscription', 'GET') + subscriptions = [] + for subscription_dict in response['subscriptions']: + subscriptions.append(Subscription(subscription_dict)) + return subscriptions + + def get_messages(self): + """Get all new (that is, as of yet unretrieved) messages. + + :return: A list of :class:`~pushjet.Message`\ s. + """ + _, response = self._request('message', 'GET') + messages = [] + for message_dict in response['messages']: + messages.append(Message(message_dict)) + return messages + +class Subscription(object): + """A subscription to a service, with the metadata that entails. + + :ivar service: The service the subscription is to, as a :class:`~pushjet.Service`. + :ivar time_subscribed: When the subscription was made, as seconds from epoch. + :ivar last_checked: When the device last retrieved messages from the subscription, + as seconds from epoch. + :ivar device_uuid: The UUID of the device that owns the subscription. + """ + + def __repr__(self): + return "".format(repr_format(self.service.name)) + + def __init__(self, subscription_dict): + self.service = Service._from_data(subscription_dict['service']) + self.time_subscribed = subscription_dict['timestamp'] + self.last_checked = subscription_dict['timestamp_checked'] + self.device_uuid = subscription_dict['uuid'] # Not sure this is needed, but... + +class Message(object): + """A message received from a service. + + :ivar message: The message body. + :ivar title: The message title. May be ``None``. + :ivar link: The URL the message links to. May be ``None``. + :ivar time_sent: When the message was sent, as seconds from epoch. + :ivar importance: The message's priority level between 1 and 5, where 1 is + least important and 5 is most. + :ivar service: The :class:`~pushjet.Service` that sent the message. + """ + + def __repr__(self): + return "".format(repr_format(self.title or self.message)) + + def __init__(self, message_dict): + self.message = message_dict['message'] + self.title = message_dict['title'] or None + self.link = message_dict['link'] or None + self.time_sent = message_dict['timestamp'] + self.importance = message_dict['level'] + self.service = Service._from_data(message_dict['service']) + +class Api(object): + """An API with a custom URL. Use this if you're connecting to a self-hosted + Pushjet API instance, or a non-standard one in general. + + :param url: The URL to the API instance. + :ivar url: The URL to the API instance, as supplied. + """ + + def __repr__(self): + return "".format(self.url).encode(sys.stdout.encoding, errors='replace') + + def __init__(self, url): + self.url = unicode_type(url) + self.Service = with_api_bound(Service, self) + self.Device = with_api_bound(Device, self) + + def _request(self, endpoint, method, params=None, data=None): + url = urljoin(self.url, endpoint) + try: + r = requests.request(method, url, params=params, data=data) + except requests.RequestException as e: + raise RequestError(e) + status = r.status_code + if status == requests.codes.INTERNAL_SERVER_ERROR: + raise ServerError( + "An error occurred in the server while processing your request. " + "This should probably be reported to: " + "https://github.com/Pushjet/Pushjet-Server-Api/issues" + ) + try: + response = r.json() + except ValueError: + response = {} + return status, response + diff --git a/apprise/plugins/NotifyPushjet/pushjet/utilities.py b/apprise/plugins/NotifyPushjet/pushjet/utilities.py new file mode 100644 index 00000000..2def6f3b --- /dev/null +++ b/apprise/plugins/NotifyPushjet/pushjet/utilities.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import re +import sys +from decorator import decorator +from .errors import AccessError + +# Help class(...es? Nah. Just singular for now.) + +class NoNoneDict(dict): + """A dict that ignores values that are None. Not completely API-compatible + with dict, but contains all that's needed. + """ + def __repr__(self): + return "NoNoneDict({dict})".format(dict=dict.__repr__(self)) + + def __init__(self, initial={}): + self.update(initial) + + def __setitem__(self, key, value): + if value is not None: + dict.__setitem__(self, key, value) + + def update(self, data): + for key, value in data.items(): + self[key] = value + +# Decorators / factories + +@decorator +def requires_secret_key(func, self, *args, **kwargs): + """Raise an error if the method is called without a secret key.""" + if self.secret_key is None: + raise AccessError("The Service doesn't have a secret " + "key provided, and therefore lacks write permission.") + return func(self, *args, **kwargs) + +def with_api_bound(cls, api): + new_cls = type(cls.__name__, (cls,), { + '_api': api, + '__doc__': ( + "Create a :class:`~pushjet.{name}` bound to the API. " + "See :class:`pushjet.{name}` for documentation." + ).format(name=cls.__name__) + }) + return new_cls + +# Helper functions + +UUID_RE = re.compile(r'^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$') +PUBLIC_KEY_RE = re.compile(r'^[A-Za-z0-9]{4}-[A-Za-z0-9]{6}-[A-Za-z0-9]{12}-[A-Za-z0-9]{5}-[A-Za-z0-9]{9}$') +SECRET_KEY_RE = re.compile(r'^[A-Za-z0-9]{32}$') + +is_valid_uuid = lambda s: UUID_RE.match(s) is not None +is_valid_public_key = lambda s: PUBLIC_KEY_RE.match(s) is not None +is_valid_secret_key = lambda s: SECRET_KEY_RE.match(s) is not None + +def repr_format(s): + s = s.replace('\n', ' ').replace('\r', '') + original_length = len(s) + s = s[:30] + s += '...' if len(s) != original_length else '' + s = s.encode(sys.stdout.encoding, errors='replace') + return s diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py new file mode 100644 index 00000000..84876883 --- /dev/null +++ b/apprise/plugins/NotifyPushover.py @@ -0,0 +1,222 @@ +# -*- encoding: utf-8 -*- +# +# Pushover Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +# Flag used as a placeholder to sending to all devices +PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' + +# Pushover uses the http protocol with JSON requests +PUSHOVER_URL = 'https://api.pushover.net/1/messages.json' + +# Used to validate API Key +VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{30}') + +# Used to detect a User and/or Group +VALIDATE_USERGROUP = re.compile(r'[A-Za-z0-9]{30}') + +# Used to detect a User and/or Group +VALIDATE_DEVICE = re.compile(r'[A-Za-z0-9_]{1,25}') + + +# Priorities +class PushoverPriority(object): + VERY_LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +PUSHOVER_PRIORITIES = ( + PushoverPriority.VERY_LOW, + PushoverPriority.MODERATE, + PushoverPriority.NORMAL, + PushoverPriority.HIGH, + PushoverPriority.EMERGENCY, +) + +# Used to break path apart into list of devices +DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +# Extend HTTP Error Messages +PUSHOVER_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 401: 'Unauthorized - Invalid Token.', +}.items()) + + +class NotifyPushover(NotifyBase): + """ + A wrapper for Pushover Notifications + """ + + # The default protocol + PROTOCOL = 'pover' + + # The default secure protocol + SECURE_PROTOCOL = 'pover' + + def __init__(self, token, devices=None, + priority=PushoverPriority.NORMAL, + **kwargs): + """ + Initialize Pushover Object + """ + super(NotifyPushover, self).__init__( + title_maxlen=250, body_maxlen=512, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if not VALIDATE_TOKEN.match(token.strip()): + self.logger.warning( + 'The API Token specified (%s) is invalid.' % token, + ) + raise TypeError( + 'The API Token specified (%s) is invalid.' % token, + ) + + # The token associated with the account + self.token = token.strip() + + if isinstance(devices, basestring): + self.devices = filter(bool, DEVICE_LIST_DELIM.split( + devices, + )) + elif isinstance(devices, (tuple, list)): + self.devices = devices + else: + self.devices = list() + + if len(self.devices) == 0: + self.devices = (PUSHOVER_SEND_TO_ALL, ) + + # The Priority of the message + if priority not in PUSHOVER_PRIORITIES: + self.priority = PushoverPriority.NORMAL + else: + self.priority = priority + + if not self.user: + self.logger.warning('No user was specified.') + raise TypeError('No user was specified.') + + if not self.token: + self.logger.warning('No token was specified.') + raise TypeError('No token was specified.') + + if not VALIDATE_USERGROUP.match(self.user): + self.logger.warning( + 'The user/group specified (%s) is invalid.' % self.user, + ) + raise TypeError( + 'The user/group specified (%s) is invalid.' % self.user, + ) + + def _notify(self, title, body, **kwargs): + """ + Perform Pushover Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded' + } + auth = (self.token, '') + + # error tracking (used for function return) + has_error = False + + # Create a copy of the devices list + devices = list(self.devices) + while len(devices): + device = devices.pop(0) + + # prepare JSON Object + payload = { + 'token': self.token, + 'user': self.user, + 'priority': str(self.priority), + 'title': title, + 'message': body, + } + + if device != PUSHOVER_SEND_TO_ALL: + if not VALIDATE_DEVICE.match(device): + self.logger.warning( + 'The device specified (%s) is invalid.' % device, + ) + has_error = True + continue + + payload['device'] = device + + self.logger.debug('Pushover POST URL: %s (cert_verify=%r)' % ( + PUSHOVER_URL, self.verify_certificate, + )) + self.logger.debug('Pushover Payload: %s' % str(payload)) + try: + r = requests.post( + PUSHOVER_URL, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Pushover:%s ' + 'notification: %s (error=%s).' % ( + device, + PUSHOVER_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Pushover:%s ' + 'notification (error=%s).' % ( + device, + r.status_code)) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Pushover:%s ' % ( + device) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(devices): + # Prevent thrashing requests + self.throttle() + + return has_error diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py new file mode 100644 index 00000000..032102ca --- /dev/null +++ b/apprise/plugins/NotifyRocketChat.py @@ -0,0 +1,307 @@ +# -*- encoding: utf-8 -*- +# +# Notify Rocket.Chat Notify Wrapper +# +# Copyright (C) 2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +import requests +import json +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9]+)$') +IS_ROOM_ID = re.compile(r'^(?P[A-Za-z0-9]+)$') + +# Extend HTTP Error Messages +RC_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 400: 'Channel/RoomId is wrong format, or missing from server.', + 401: 'Authentication tokens provided is invalid or missing.', +}.items()) + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyRocketChat(NotifyBase): + """ + A wrapper for Notify Rocket.Chat Notifications + """ + + # The default protocol + PROTOCOL = 'rocket' + + # The default secure protocol + SECURE_PROTOCOL = 'rockets' + + def __init__(self, recipients=None, **kwargs): + """ + Initialize Notify Rocket.Chat Object + """ + super(NotifyRocketChat, self).__init__( + title_maxlen=200, body_maxlen=32768, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + # Prepare our URL + self.api_url = '%s://%s' % (self.schema, self.host) + + if isinstance(self.port, int): + self.api_url += ':%d' % self.port + + self.api_url += '/api/v1/' + + # Initialize channels list + self.channels = list() + + # Initialize room_id list + self.room_ids = list() + + if recipients is None: + recipients = [] + + elif isinstance(recipients, basestring): + recipients = filter(bool, LIST_DELIM.split( + recipients, + )) + + elif not isinstance(recipients, (tuple, list)): + recipients = [] + + # Validate recipients and drop bad ones: + for recipient in recipients: + result = IS_CHANNEL.match(recipient) + if result: + # store valid device + self.channels.append(result.group('name')) + continue + + result = IS_ROOM_ID.match(recipient) + if result: + # store valid room_id + self.channels.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid channel/room_id ' + + '(%s) specified.' % recipient, + ) + + if len(self.room_ids) == 0 and len(self.channels) == 0: + raise TypeError( + 'No Rocket.Chat room_id and/or channels specified to notify.' + ) + + # Used to track token headers upon authentication (if successful) + self.headers = {} + + # Track whether we authenticated okay + self.authenticated = self.login() + + if not self.authenticated: + raise TypeError( + 'Authentication to Rocket.Chat server failed.' + ) + + def _notify(self, title, body, notify_type, **kwargs): + """ + wrapper to send_notification since we can alert more then one channel + """ + + # Prepare our message + text = '*%s*\r\n%s' % (title.replace('*', '\*'), body) + + # Send all our defined channels + for channel in self.channels: + self.send_notification({ + 'text': text, + 'channel': channel, + }, notify_type=notify_type, **kwargs) + + # Send all our defined room id's + for room_id in self.room_ids: + self.send_notification({ + 'text': text, + 'roomId': room_id, + }, notify_type=notify_type, **kwargs) + + def send_notification(self, payload, notify_type, **kwargs): + """ + Perform Notify Rocket.Chat Notification + """ + + if not self.authenticated: + # We couldn't authenticate; we're done + return False + + self.logger.debug('Rocket.Chat POST URL: %s (cert_verify=%r)' % ( + self.api_url + 'chat.postMessage', self.verify_certificate, + )) + self.logger.debug('Rocket.Chat Payload: %s' % str(payload)) + try: + r = requests.post( + self.api_url + 'chat.postMessage', + data=payload, + headers=self.headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Rocket.Chat notification: ' + + '%s (error=%s).' % ( + RC_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + except IndexError: + self.logger.warning( + 'Failed to send Rocket.Chat notification ' + + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + else: + self.logger.debug('Rocket.Chat Server Response: %s.' % r.text) + self.logger.info('Sent Rocket.Chat notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Rocket.Chat ' + + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def login(self): + """ + login to our server + """ + payload = { + 'username': self.user, + 'password': self.password, + } + + try: + r = requests.post( + self.api_url + 'login', + data=payload, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to authenticate with Rocket.Chat server: ' + + '%s (error=%s).' % ( + RC_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + except IndexError: + self.logger.warning( + 'Failed to authenticate with Rocket.Chat server ' + + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + else: + self.logger.debug('Rocket.Chat authentication successful') + response = json.loads(r.text) + if response.get('status') != "success": + self.logger.warning( + 'Could not authenticate with Rocket.Chat server.') + return False + + # Set our headers for further communication + self.headers['X-Auth-Token'] = \ + response.get('data').get('authToken') + self.headers['X-User-Id'] = \ + response.get('data').get('userId') + + # We're authenticated now + self.authenticated = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured authenticating to the ' + + 'Rocket.Chat server.') + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def logout(self): + """ + logout of our server + """ + if not self.authenticated: + # Nothing to do + return True + + try: + r = requests.post( + self.api_url + 'logout', + headers=self.headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to log off Rocket.Chat server: ' + + '%s (error=%s).' % ( + RC_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + except IndexError: + self.logger.warning( + 'Failed to log off Rocket.Chat server ' + + '(error=%s).' % ( + r.status_code)) + + # Return; we're done + return False + + else: + self.logger.debug( + 'Rocket.Chat log off successful; response %s.' % ( + r.text)) + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured logging off the ' + + 'Rocket.Chat server') + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + # We're no longer authenticated now + self.authenticated = False + return True diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py new file mode 100644 index 00000000..7390f369 --- /dev/null +++ b/apprise/plugins/NotifySlack.py @@ -0,0 +1,287 @@ +# -*- encoding: utf-8 -*- +# +# Slack Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +# To use this plugin, you need to first access https://api.slack.com +# Specifically https://my.slack.com/services/new/incoming-webhook/ +# 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://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F +# ^ ^ ^ +# | | | +# These are important <--------------^---------^---------------^ +# +# +import requests +import re + +from json import dumps +from time import time + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP +from .NotifyBase import HTML_NOTIFY_MAP +from .NotifyBase import NotifyImageSize + +# Slack uses the http protocol with JSON requests +SLACK_URL = 'https://hooks.slack.com/services' + +# Token required as part of the API request +# /AAAAAAAAA/........./........................ +VALIDATE_TOKEN_A = re.compile(r'[A-Z0-9]{9}') + +# Token required as part of the API request +# /........./BBBBBBBBB/........................ +VALIDATE_TOKEN_B = re.compile(r'[A-Z0-9]{9}') + +# Token required as part of the API request +# /........./........./CCCCCCCCCCCCCCCCCCCCCCCC +VALIDATE_TOKEN_C = re.compile(r'[A-Za-z0-9]{24}') + +# Default User +SLACK_DEFAULT_USER = 'apprise' + +# Extend HTTP Error Messages +SLACK_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { + 401: 'Unauthorized - Invalid Token.', +}.items()) + +# Used to break path apart into list of devices +CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + +# Used to detect a device +IS_CHANNEL_RE = re.compile(r'#?([A-Za-z0-9_]{1,32})') + +# Image Support (72x72) +SLACK_IMAGE_XY = NotifyImageSize.XY_72 + + +class NotifySlack(NotifyBase): + """ + A wrapper for Slack Notifications + """ + + # The default protocol + PROTOCOL = 'slack' + + # The default secure protocol + SECURE_PROTOCOL = 'slack' + + def __init__(self, token_a, token_b, token_c, channels, **kwargs): + """ + Initialize Slack Object + """ + super(NotifySlack, self).__init__( + title_maxlen=250, body_maxlen=1000, + image_size=SLACK_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if not VALIDATE_TOKEN_A.match(token_a.strip()): + self.logger.warning( + 'The first API Token specified (%s) is invalid.' % token_a, + ) + raise TypeError( + 'The first API Token specified (%s) is invalid.' % token_a, + ) + + # The token associated with the account + self.token_a = token_a.strip() + + if not VALIDATE_TOKEN_B.match(token_b.strip()): + self.logger.warning( + 'The second API Token specified (%s) is invalid.' % token_b, + ) + raise TypeError( + 'The second API Token specified (%s) is invalid.' % token_b, + ) + + # The token associated with the account + self.token_b = token_b.strip() + + if not VALIDATE_TOKEN_C.match(token_c.strip()): + self.logger.warning( + 'The third API Token specified (%s) is invalid.' % token_c, + ) + raise TypeError( + 'The third API Token specified (%s) is invalid.' % token_c, + ) + + # The token associated with the account + self.token_c = token_c.strip() + + if not self.user: + self.logger.warning( + 'No user was specified; using %s.' % SLACK_DEFAULT_USER) + self.user = SLACK_DEFAULT_USER + + if isinstance(channels, basestring): + self.channels = filter(bool, CHANNEL_LIST_DELIM.split( + channels, + )) + elif isinstance(channels, (tuple, list)): + self.channels = channels + else: + self.channels = list() + + if len(self.channels) == 0: + self.logger.warning('No channel(s) were specified.') + raise TypeError('No channel(s) were specified.') + + # Formatting requirements are defined here: + # https://api.slack.com/docs/message-formatting + self._re_formatting_map = { + # New lines must become the string version + '\r\*\n': '\\n', + # Escape other special characters + '&': '&', + '<': '<', + '>': '>', + } + + # Iterate over above list and store content accordingly + self._re_formatting_rules = re.compile( + r'(' + '|'.join(self._re_formatting_map.keys()) + r')', + re.IGNORECASE, + ) + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Slack Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # error tracking (used for function return) + has_error = False + + # Perform Formatting + title = self._re_formatting_rules.sub( + lambda x: self._re_formatting_map[x.group()], title, + ) + body = self._re_formatting_rules.sub( + lambda x: self._re_formatting_map[x.group()], body, + ) + url = '%s/%s/%s/%s' % ( + SLACK_URL, + self.token_a, + self.token_b, + self.token_c, + ) + + image_url = None + if self.include_image: + image_url = self.image_url( + notify_type, + ) + + # Create a copy of the channel list + channels = list(self.channels) + while len(channels): + channel = channels.pop(0) + if not IS_CHANNEL_RE.match(channel): + self.logger.warning( + "The specified channel '%s' is invalid; skipping." % ( + channel, + ) + ) + continue + + if len(channel) > 1 and channel[0] == '+': + # Treat as encoded id if prefixed with a + + _channel = channel[1:] + elif len(channel) > 1 and channel[0] == '@': + # Treat @ value 'as is' + _channel = channel + else: + # Prefix with channel hash tag + _channel = '#%s' % channel + + # prepare JSON Object + payload = { + 'channel': _channel, + 'username': self.user, + # Use Markdown language + 'mrkdwn': True, + 'attachments': [{ + 'title': title, + 'text': body, + 'color': HTML_NOTIFY_MAP[notify_type], + # Time + 'ts': time(), + 'footer': self.app_id, + }], + } + + if image_url: + payload['attachments'][0]['footer_icon'] = image_url + + self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Slack Payload: %s' % str(payload)) + 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 + try: + self.logger.warning( + 'Failed to send Slack:%s ' + 'notification: %s (error=%s).' % ( + channel, + SLACK_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Slack:%s ' + 'notification (error=%s).' % ( + channel, + r.status_code)) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Slack:%s ' % ( + channel) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(channels): + # Prevent thrashing requests + self.throttle() + + return has_error diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py new file mode 100644 index 00000000..ce28fbbb --- /dev/null +++ b/apprise/plugins/NotifyTelegram.py @@ -0,0 +1,412 @@ +# -*- encoding: utf-8 -*- +# +# Telegram Notify Wrapper +# +# Copyright (C) 2016-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +# To use this plugin, you need to first access https://api.telegram.org +# You need to create a bot and acquire it's Token Identifier (bot_token) +# +# Basically you need to create a chat with a user called the 'BotFather' +# and type: /newbot +# +# Then follow through the wizard, it will provide you an api key +# that looks like this:123456789:alphanumeri_characters +# +# For each chat_id a bot joins will have a chat_id associated with it. +# You will need this value as well to send the notification. +# +# Log into the webpage version of the site if you like by accessing: +# https://web.telegram.org +# +# You can't check out to see if your entry is working using: +# https://api.telegram.org/botAPI_KEY/getMe +# +# Pay attention to the word 'bot' that must be present infront of your +# api key that the BotFather gave you. +# +# For example, a url might look like this: +# https://api.telegram.org/bot123456789:alphanumeri_characters/getMe +# +import requests +import re + +from json import loads +from json import dumps + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import HTTP_ERROR_MAP + +# Telegram uses the http protocol with JSON requests +TELEGRAM_BOT_URL = 'https://api.telegram.org/bot' + +# Token required as part of the API request +# allow the word 'bot' infront +VALIDATE_BOT_TOKEN = re.compile( + r'(bot)?(?P[0-9]+:[A-Za-z0-9_-]+)/*$', + re.IGNORECASE, +) + +# Chat ID is required +# If the Chat ID is positive, then it's addressed to a single person +# If the Chat ID is negative, then it's targeting a group +IS_CHAT_ID_RE = re.compile( + r'(@*(?P-?[0-9]{1,32})|(?P[a-z_-][a-z0-9_-]*))', + re.IGNORECASE, +) + +# Disable image support for now +# The stickers/images are kind of big and consume a lot of space +# It's not as appealing as just having the post not contain +# an image at all. +TELEGRAM_IMAGE_XY = None + +# Used to break path apart into list of chat identifiers +CHAT_ID_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + + +class NotifyTelegram(NotifyBase): + """ + A wrapper for Telegram Notifications + """ + + # The default protocol + PROTOCOL = 'tgram' + + # The default secure protocol + SECURE_PROTOCOL = 'tgram' + + def __init__(self, bot_token, chat_ids, **kwargs): + """ + Initialize Telegram Object + """ + super(NotifyTelegram, self).__init__( + title_maxlen=250, body_maxlen=4096, + image_size=TELEGRAM_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if bot_token is None: + raise TypeError( + 'The Bot Token specified is invalid.' + ) + + result = VALIDATE_BOT_TOKEN.match(bot_token.strip()) + if not result: + raise TypeError( + 'The Bot Token specified (%s) is invalid.' % bot_token, + ) + + # Store our API Key + self.bot_token = result.group('key') + + if isinstance(chat_ids, basestring): + self.chat_ids = filter(bool, CHAT_ID_LIST_DELIM.split( + chat_ids, + )) + elif isinstance(chat_ids, (tuple, list)): + self.chat_ids = list(chat_ids) + + else: + self.chat_ids = list() + + if self.user: + # Treat this as a channel too + self.chat_ids.append(self.user) + + # Bot's can't send messages to themselves which is fair enough + # but if or when they can, this code will allow a default fallback + # solution if no chat_id and/or channel is specified + # if len(self.chat_ids) == 0: + # + # chat_id = self._get_chat_id() + # if chat_id is not None: + # self.logger.warning( + # 'No chat_id or @channel was specified; ' +\ + # 'using detected bot_chat_id (%d).' % chat_id, + # ) + # self.chat_ids.append(str(chat_id)) + + if len(self.chat_ids) == 0: + self.logger.warning('No chat_id(s) were specified.') + raise TypeError('No chat_id(s) were specified.') + + def _get_chat_id(self): + """ + This function retrieves the chat id belonging to the key specified + """ + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + url = '%s%s/%s' % ( + TELEGRAM_BOT_URL, + self.bot_token, + 'getMe' + ) + + self.logger.debug('Telegram (Detection) GET URL: %s' % url) + + chat_id = None + try: + r = requests.post(url, headers=headers) + if r.status_code == requests.codes.ok: + # Extract our chat ID + result = loads(r.text) + if result.get('ok', False) is True: + chat_id = result['result'].get('id') + if chat_id <= 0: + chat_id = None + else: + # We had a problem + try: + # Try to get the error message if we can: + error_msg = loads(r.text)['description'] + + except: + error_msg = None + + try: + if error_msg: + self.logger.warning( + 'Failed to lookup Telegram chat_id from ' + 'apikey: (%s) %s.' % (r.status_code, error_msg)) + + else: + self.logger.warning( + 'Failed to lookup Telegram chat_id from ' + 'apikey: %s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to lookup Telegram chat_id from ' + 'apikey: (error=%s).' % r.status_code) + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured looking up Telegram chat_id ' + 'from apikey.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return chat_id + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Telegram Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # error tracking (used for function return) + has_error = False + + image_url = None + if self.include_image: + image_content = self.image_raw( + notify_type, + ) + if image_content is not None: + # prepare our eimage URL + image_url = '%s%s/%s' % ( + TELEGRAM_BOT_URL, + self.bot_token, + 'sendPhoto' + ) + + # Set up our upload + files = {'photo': ('%s.png' % notify_type, image_content)} + + url = '%s%s/%s' % ( + TELEGRAM_BOT_URL, + self.bot_token, + 'sendMessage' + ) + + payload = {} + + if self.notify_format == NotifyFormat.HTML: + # HTML + payload['parse_mode'] = 'HTML' + payload['text'] = '%s\r\n%s' % (title, body) + + else: + # Text + # payload['parse_mode'] = 'Markdown' + payload['parse_mode'] = 'HTML' + payload['text'] = '%s\r\n%s' % ( + self.escape_html(title), + self.escape_html(body), + ) + + # Create a copy of the chat_ids list + chat_ids = list(self.chat_ids) + while len(chat_ids): + chat_id = chat_ids.pop(0) + chat_id = IS_CHAT_ID_RE.match(chat_id) + if not chat_id: + self.logger.warning( + "The specified chat_id '%s' is invalid; skipping." % ( + chat_id, + ) + ) + continue + + if chat_id.group('name') is not None: + # Name + payload['chat_id'] = '@%s' % chat_id.group('name') + + else: + # ID + payload['chat_id'] = chat_id.group('idno') + + if image_url is not None: + image_payload = { + 'chat_id': payload['chat_id'], + 'disable_notification': True, + } + + self.logger.debug( + 'Telegram (image) POST URL: %s (cert_verify=%r)' % ( + image_url, self.verify_certificate)) + + self.logger.debug( + 'Telegram (image) Payload: %s' % str(image_payload)) + + try: + r = requests.post( + image_url, + data=image_payload, + headers={ + 'User-Agent': self.app_id, + }, + files=files, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + + try: + # Try to get the error message if we can: + error_msg = loads(r.text)['description'] + except: + error_msg = None + + try: + if error_msg: + self.logger.warning( + 'Failed to send Telegram Image:%s ' + 'notification: (%s) %s.' % ( + payload['chat_id'], + r.status_code, error_msg)) + + else: + self.logger.warning( + 'Failed to send Telegram Image:%s ' + 'notification: %s (error=%s).' % ( + payload['chat_id'], + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Telegram Image:%s ' + 'notification (error=%s).' % ( + payload['chat_id'], + r.status_code)) + + has_error = True + continue + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Telegram:%s ' % ( + payload['chat_id']) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + continue + + self.logger.debug('Telegram POST URL: %s' % url) + self.logger.debug('Telegram POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Telegram Payload: %s' % str(payload)) + + 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 + + try: + # Try to get the error message if we can: + error_msg = loads(r.text)['description'] + except: + error_msg = None + + try: + if error_msg: + self.logger.warning( + 'Failed to send Telegram:%s ' + 'notification: (%s) %s.' % ( + payload['chat_id'], + r.status_code, error_msg)) + + else: + self.logger.warning( + 'Failed to send Telegram:%s ' + 'notification: %s (error=%s).' % ( + payload['chat_id'], + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Telegram:%s ' + 'notification (error=%s).' % ( + payload['chat_id'], r.status_code)) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Telegram:%s ' % ( + payload['chat_id']) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(chat_ids): + # Prevent thrashing requests + self.throttle() + + return has_error diff --git a/apprise/plugins/NotifyToasty.py b/apprise/plugins/NotifyToasty.py new file mode 100644 index 00000000..3f3a3ac9 --- /dev/null +++ b/apprise/plugins/NotifyToasty.py @@ -0,0 +1,155 @@ +# -*- encoding: utf-8 -*- +# +# (Super) Toasty Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from urllib import quote +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Toasty uses the http protocol with JSON requests +TOASTY_URL = 'http://api.supertoasty.com/notify/' + +# Image Support (128x128) +TOASTY_IMAGE_XY = NotifyImageSize.XY_128 + +# Used to break apart list of potential devices by their delimiter +# into a usable list. +DEVICES_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyToasty(NotifyBase): + """ + A wrapper for Toasty Notifications + """ + + # The default protocol + PROTOCOL = 'toasty' + + # The default secure protocol + SECURE_PROTOCOL = 'toasty' + + def __init__(self, devices, **kwargs): + """ + Initialize Toasty Object + """ + super(NotifyToasty, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=TOASTY_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if isinstance(devices, basestring): + self.devices = filter(bool, DEVICES_LIST_DELIM.split( + devices, + )) + elif isinstance(devices, (tuple, list)): + self.devices = devices + else: + raise TypeError('You must specify at least 1 device.') + + if not self.user: + raise TypeError('You must specify a username.') + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Toasty Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'multipart/form-data', + } + + # error tracking (used for function return) + has_error = False + + # Create a copy of the devices list + devices = list(self.devices) + while len(devices): + device = devices.pop(0) + + # prepare JSON Object + payload = { + 'sender': quote(self.user), + 'title': quote(title), + 'text': quote(body), + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + payload['image'] = image_url + + # URL to transmit content via + url = '%s%s' % (TOASTY_URL, device) + + self.logger.debug('Toasty POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Toasty Payload: %s' % str(payload)) + try: + r = requests.get( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Toasty:%s ' + 'notification: %s (error=%s).' % ( + device, + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except IndexError: + self.logger.warning( + 'Failed to send Toasty:%s ' + 'notification (error=%s).' % ( + device, + r.status_code)) + + # self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + has_error = True + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending Toasty:%s ' % ( + device) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + has_error = True + + if len(devices): + # Prevent thrashing requests + self.throttle() + + return has_error diff --git a/apprise/plugins/NotifyTwitter/NotifyTwitter.py b/apprise/plugins/NotifyTwitter/NotifyTwitter.py new file mode 100644 index 00000000..9c918b6f --- /dev/null +++ b/apprise/plugins/NotifyTwitter/NotifyTwitter.py @@ -0,0 +1,116 @@ +# -*- encoding: utf-8 -*- +# +# Twitter Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from . import tweepy +from ..NotifyBase import NotifyBase +from ..NotifyBase import NotifyFormat + +# Direct Messages have not image support +TWITTER_IMAGE_XY = None + + +class NotifyTwitter(NotifyBase): + """ + A wrapper to Twitter Notifications + + """ + + # The default protocol + PROTOCOL = 'tweet' + + # The default secure protocol + SECURE_PROTOCOL = 'tweet' + + def __init__(self, ckey, csecret, akey, asecret, **kwargs): + """ + Initialize Twitter Object + + Tweets are restriced to 140 (soon to be 240), but DM messages + do not have any restriction on them + """ + super(NotifyTwitter, self).__init__( + title_maxlen=250, body_maxlen=4096, + image_size=TWITTER_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if not ckey: + raise TypeError( + 'An invalid Consumer API Key was specified.' + ) + + if not csecret: + raise TypeError( + 'An invalid Consumer Secret API Key was specified.' + ) + + if not akey: + raise TypeError( + 'An invalid Acess Token API Key was specified.' + ) + + if not asecret: + raise TypeError( + 'An invalid Acess Token Secret API Key was specified.' + ) + + if not self.user: + raise TypeError( + 'No user was specified.' + ) + + try: + # Attempt to Establish a connection to Twitter + self.auth = tweepy.OAuthHandler(ckey, csecret) + # Apply our Access Tokens + self.auth.set_access_token(akey, asecret) + + except Exception: + raise TypeError( + 'Twitter authentication failed; ' + 'please verify your configuration.' + ) + + return + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform Twitter Notification + """ + + text = '%s\r\n%s' % (title, body) + try: + # Get our API + api = tweepy.API(self.auth) + + # Send our Direct Message + api.send_direct_message(self.user, text=text) + + except Exception as e: + self.logger.warning( + 'A Connection error occured sending Twitter ' + 'direct message to %s.' % self.user) + self.logger.debug('Twitter Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyTwitter/__init__.py b/apprise/plugins/NotifyTwitter/__init__.py new file mode 100644 index 00000000..903ad189 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/__init__.py @@ -0,0 +1,6 @@ +# -*- encoding: utf-8 -*- +from . import NotifyTwitter + +__all__ = [ + 'NotifyTwitter', +] diff --git a/apprise/plugins/NotifyTwitter/tweepy/__init__.py b/apprise/plugins/NotifyTwitter/tweepy/__init__.py new file mode 100644 index 00000000..6b2575d2 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/__init__.py @@ -0,0 +1,25 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +""" +Tweepy Twitter API library +""" +__version__ = '3.5.0' +__author__ = 'Joshua Roesslein' +__license__ = 'MIT' + +from .models import Status, User, DirectMessage, Friendship, SavedSearch, SearchResults, ModelFactory, Category +from .error import TweepError, RateLimitError +from .api import API +from .cache import Cache, MemoryCache, FileCache +from .auth import OAuthHandler, AppAuthHandler +from .streaming import Stream, StreamListener +from .cursor import Cursor + +# Global, unauthenticated instance of API +api = API() + +def debug(enable=True, level=1): + from six.moves.http_client import HTTPConnection + HTTPConnection.debuglevel = level diff --git a/apprise/plugins/NotifyTwitter/tweepy/api.py b/apprise/plugins/NotifyTwitter/tweepy/api.py new file mode 100644 index 00000000..6f970842 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/api.py @@ -0,0 +1,1348 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +import os +import mimetypes + +import six + +from .binder import bind_api +from .error import TweepError +from .parsers import ModelParser, Parser +from .utils import list_to_csv + + +class API(object): + """Twitter API""" + + def __init__(self, auth_handler=None, + host='api.twitter.com', search_host='search.twitter.com', + upload_host='upload.twitter.com', cache=None, api_root='/1.1', + search_root='', upload_root='/1.1', retry_count=0, + retry_delay=0, retry_errors=None, timeout=60, parser=None, + compression=False, wait_on_rate_limit=False, + wait_on_rate_limit_notify=False, proxy=''): + """ Api instance Constructor + + :param auth_handler: + :param host: url of the server of the rest api, default:'api.twitter.com' + :param search_host: url of the search server, default:'search.twitter.com' + :param upload_host: url of the upload server, default:'upload.twitter.com' + :param cache: Cache to query if a GET method is used, default:None + :param api_root: suffix of the api version, default:'/1.1' + :param search_root: suffix of the search version, default:'' + :param upload_root: suffix of the upload version, default:'/1.1' + :param retry_count: number of allowed retries, default:0 + :param retry_delay: delay in second between retries, default:0 + :param retry_errors: default:None + :param timeout: delay before to consider the request as timed out in seconds, default:60 + :param parser: ModelParser instance to parse the responses, default:None + :param compression: If the response is compressed, default:False + :param wait_on_rate_limit: If the api wait when it hits the rate limit, default:False + :param wait_on_rate_limit_notify: If the api print a notification when the rate limit is hit, default:False + :param proxy: Url to use as proxy during the HTTP request, default:'' + + :raise TypeError: If the given parser is not a ModelParser instance. + """ + self.auth = auth_handler + self.host = host + self.search_host = search_host + self.upload_host = upload_host + self.api_root = api_root + self.search_root = search_root + self.upload_root = upload_root + self.cache = cache + self.compression = compression + self.retry_count = retry_count + self.retry_delay = retry_delay + self.retry_errors = retry_errors + self.timeout = timeout + self.wait_on_rate_limit = wait_on_rate_limit + self.wait_on_rate_limit_notify = wait_on_rate_limit_notify + self.parser = parser or ModelParser() + self.proxy = {} + if proxy: + self.proxy['https'] = proxy + + # Attempt to explain more clearly the parser argument requirements + # https://github.com/tweepy/tweepy/issues/421 + # + parser_type = Parser + if not isinstance(self.parser, parser_type): + raise TypeError( + '"parser" argument has to be an instance of "{required}".' + ' It is currently a {actual}.'.format( + required=parser_type.__name__, + actual=type(self.parser) + ) + ) + + @property + def home_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/home_timeline + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/home_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + def statuses_lookup(self, id_, include_entities=None, + trim_user=None, map_=None): + return self._statuses_lookup(list_to_csv(id_), include_entities, + trim_user, map_) + + @property + def _statuses_lookup(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/lookup + :allowed_param:'id', 'include_entities', 'trim_user', 'map' + """ + return bind_api( + api=self, + path='/statuses/lookup.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'include_entities', 'trim_user', 'map'], + require_auth=True + ) + + @property + def user_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/user_timeline + :allowed_param:'id', 'user_id', 'screen_name', 'since_id' + """ + return bind_api( + api=self, + path='/statuses/user_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'since_id', + 'max_id', 'count', 'include_rts'] + ) + + @property + def mentions_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/mentions_timeline + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/mentions_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + @property + def related_results(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/related_results/show/%3id.format + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/related_results/show/{id}.json', + payload_type='relation', payload_list=True, + allowed_param=['id'], + require_auth=False + ) + + @property + def retweets_of_me(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweets_of_me + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/retweets_of_me.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + @property + def get_status(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/show/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/show.json', + payload_type='status', + allowed_param=['id'] + ) + + def update_status(self, *args, **kwargs): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/update + :allowed_param:'status', 'in_reply_to_status_id', 'lat', 'long', 'source', 'place_id', 'display_coordinates', 'media_ids' + """ + post_data = {} + media_ids = kwargs.pop("media_ids", None) + if media_ids is not None: + post_data["media_ids"] = list_to_csv(media_ids) + + return bind_api( + api=self, + path='/statuses/update.json', + method='POST', + payload_type='status', + allowed_param=['status', 'in_reply_to_status_id', 'lat', 'long', 'source', 'place_id', 'display_coordinates'], + require_auth=True + )(post_data=post_data, *args, **kwargs) + + def media_upload(self, filename, *args, **kwargs): + """ :reference: https://dev.twitter.com/rest/reference/post/media/upload + :allowed_param: + """ + f = kwargs.pop('file', None) + headers, post_data = API._pack_image(filename, 3072, form_field='media', f=f) + kwargs.update({'headers': headers, 'post_data': post_data}) + + return bind_api( + api=self, + path='/media/upload.json', + method='POST', + payload_type='media', + allowed_param=[], + require_auth=True, + upload_api=True + )(*args, **kwargs) + + def update_with_media(self, filename, *args, **kwargs): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/update_with_media + :allowed_param:'status', 'possibly_sensitive', 'in_reply_to_status_id', 'lat', 'long', 'place_id', 'display_coordinates' + """ + f = kwargs.pop('file', None) + headers, post_data = API._pack_image(filename, 3072, form_field='media[]', f=f) + kwargs.update({'headers': headers, 'post_data': post_data}) + + return bind_api( + api=self, + path='/statuses/update_with_media.json', + method='POST', + payload_type='status', + allowed_param=[ + 'status', 'possibly_sensitive', 'in_reply_to_status_id', 'lat', 'long', + 'place_id', 'display_coordinates' + ], + require_auth=True + )(*args, **kwargs) + + @property + def destroy_status(self): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/destroy/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/destroy/{id}.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def retweet(self): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/retweet/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/retweet/{id}.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def retweets(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweets/%3Aid + :allowed_param:'id', 'count' + """ + return bind_api( + api=self, + path='/statuses/retweets/{id}.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'count'], + require_auth=True + ) + + @property + def retweeters(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweeters/ids + :allowed_param:'id', 'cursor', 'stringify_ids + """ + return bind_api( + api=self, + path='/statuses/retweeters/ids.json', + payload_type='ids', + allowed_param=['id', 'cursor', 'stringify_ids'] + ) + + @property + def get_user(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/show + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/users/show.json', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'] + ) + + @property + def get_oembed(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/oembed + :allowed_param:'id', 'url', 'maxwidth', 'hide_media', 'omit_script', 'align', 'related', 'lang' + """ + return bind_api( + api=self, + path='/statuses/oembed.json', + payload_type='json', + allowed_param=['id', 'url', 'maxwidth', 'hide_media', 'omit_script', 'align', 'related', 'lang'] + ) + + def lookup_users(self, user_ids=None, screen_names=None, include_entities=None): + """ Perform bulk look up of users from user ID or screenname """ + post_data = {} + if include_entities is not None: + include_entities = 'true' if include_entities else 'false' + post_data['include_entities'] = include_entities + if user_ids: + post_data['user_id'] = list_to_csv(user_ids) + if screen_names: + post_data['screen_name'] = list_to_csv(screen_names) + + return self._lookup_users(post_data=post_data) + + @property + def _lookup_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/lookup + allowed_param='user_id', 'screen_name', 'include_entities' + """ + return bind_api( + api=self, + path='/users/lookup.json', + payload_type='user', payload_list=True, + method='POST', + ) + + def me(self): + """ Get the authenticated user """ + return self.get_user(screen_name=self.auth.get_username()) + + @property + def search_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/search + :allowed_param:'q', 'count', 'page' + """ + return bind_api( + api=self, + path='/users/search.json', + payload_type='user', payload_list=True, + require_auth=True, + allowed_param=['q', 'count', 'page'] + ) + + @property + def suggested_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions/%3Aslug + :allowed_param:'slug', 'lang' + """ + return bind_api( + api=self, + path='/users/suggestions/{slug}.json', + payload_type='user', payload_list=True, + require_auth=True, + allowed_param=['slug', 'lang'] + ) + + @property + def suggested_categories(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions + :allowed_param:'lang' + """ + return bind_api( + api=self, + path='/users/suggestions.json', + payload_type='category', payload_list=True, + allowed_param=['lang'], + require_auth=True + ) + + @property + def suggested_users_tweets(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions/%3Aslug/members + :allowed_param:'slug' + """ + return bind_api( + api=self, + path='/users/suggestions/{slug}/members.json', + payload_type='status', payload_list=True, + allowed_param=['slug'], + require_auth=True + ) + + @property + def direct_messages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages + :allowed_param:'since_id', 'max_id', 'count', 'full_text' + """ + return bind_api( + api=self, + path='/direct_messages.json', + payload_type='direct_message', payload_list=True, + allowed_param=['since_id', 'max_id', 'count', 'full_text'], + require_auth=True + ) + + @property + def get_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages/show + :allowed_param:'id', 'full_text' + """ + return bind_api( + api=self, + path='/direct_messages/show/{id}.json', + payload_type='direct_message', + allowed_param=['id', 'full_text'], + require_auth=True + ) + + @property + def sent_direct_messages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages/sent + :allowed_param:'since_id', 'max_id', 'count', 'page', 'full_text' + """ + return bind_api( + api=self, + path='/direct_messages/sent.json', + payload_type='direct_message', payload_list=True, + allowed_param=['since_id', 'max_id', 'count', 'page', 'full_text'], + require_auth=True + ) + + @property + def send_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/post/direct_messages/new + :allowed_param:'user', 'screen_name', 'user_id', 'text' + """ + return bind_api( + api=self, + path='/direct_messages/new.json', + method='POST', + payload_type='direct_message', + allowed_param=['user', 'screen_name', 'user_id', 'text'], + require_auth=True + ) + + @property + def destroy_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/post/direct_messages/destroy + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/direct_messages/destroy.json', + method='POST', + payload_type='direct_message', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/post/friendships/create + :allowed_param:'id', 'user_id', 'screen_name', 'follow' + """ + return bind_api( + api=self, + path='/friendships/create.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name', 'follow'], + require_auth=True + ) + + @property + def destroy_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/post/friendships/destroy + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/friendships/destroy.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def show_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/show + :allowed_param:'source_id', 'source_screen_name' + """ + return bind_api( + api=self, + path='/friendships/show.json', + payload_type='friendship', + allowed_param=['source_id', 'source_screen_name', + 'target_id', 'target_screen_name'] + ) + + def lookup_friendships(self, user_ids=None, screen_names=None): + """ Perform bulk look up of friendships from user ID or screenname """ + return self._lookup_friendships(list_to_csv(user_ids), list_to_csv(screen_names)) + + @property + def _lookup_friendships(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/lookup + :allowed_param:'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/friendships/lookup.json', + payload_type='relationship', payload_list=True, + allowed_param=['user_id', 'screen_name'], + require_auth=True + ) + + @property + def friends_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friends/ids + :allowed_param:'id', 'user_id', 'screen_name', 'cursor' + """ + return bind_api( + api=self, + path='/friends/ids.json', + payload_type='ids', + allowed_param=['id', 'user_id', 'screen_name', 'cursor'] + ) + + @property + def friends(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friends/list + :allowed_param:'id', 'user_id', 'screen_name', 'cursor', 'skip_status', 'include_user_entities' + """ + return bind_api( + api=self, + path='/friends/list.json', + payload_type='user', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'cursor', 'skip_status', 'include_user_entities'] + ) + + @property + def friendships_incoming(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/incoming + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/friendships/incoming.json', + payload_type='ids', + allowed_param=['cursor'] + ) + + @property + def friendships_outgoing(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/outgoing + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/friendships/outgoing.json', + payload_type='ids', + allowed_param=['cursor'] + ) + + @property + def followers_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/followers/ids + :allowed_param:'id', 'user_id', 'screen_name', 'cursor', 'count' + """ + return bind_api( + api=self, + path='/followers/ids.json', + payload_type='ids', + allowed_param=['id', 'user_id', 'screen_name', 'cursor', 'count'] + ) + + @property + def followers(self): + """ :reference: https://dev.twitter.com/rest/reference/get/followers/list + :allowed_param:'id', 'user_id', 'screen_name', 'cursor', 'count', 'skip_status', 'include_user_entities' + """ + return bind_api( + api=self, + path='/followers/list.json', + payload_type='user', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'cursor', 'count', + 'skip_status', 'include_user_entities'] + ) + + @property + def get_settings(self): + """ :reference: https://dev.twitter.com/rest/reference/get/account/settings + """ + return bind_api( + api=self, + path='/account/settings.json', + payload_type='json', + use_cache=False + ) + + @property + def set_settings(self): + """ :reference: https://dev.twitter.com/rest/reference/post/account/settings + :allowed_param:'sleep_time_enabled', 'start_sleep_time', + 'end_sleep_time', 'time_zone', 'trend_location_woeid', + 'allow_contributor_request', 'lang' + """ + return bind_api( + api=self, + path='/account/settings.json', + method='POST', + payload_type='json', + allowed_param=['sleep_time_enabled', 'start_sleep_time', + 'end_sleep_time', 'time_zone', + 'trend_location_woeid', 'allow_contributor_request', + 'lang'], + use_cache=False + ) + + def verify_credentials(self, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/get/account/verify_credentials + :allowed_param:'include_entities', 'skip_status', 'include_email' + """ + try: + return bind_api( + api=self, + path='/account/verify_credentials.json', + payload_type='user', + require_auth=True, + allowed_param=['include_entities', 'skip_status', 'include_email'], + )(**kargs) + except TweepError as e: + if e.response and e.response.status == 401: + return False + raise + + @property + def rate_limit_status(self): + """ :reference: https://dev.twitter.com/rest/reference/get/application/rate_limit_status + :allowed_param:'resources' + """ + return bind_api( + api=self, + path='/application/rate_limit_status.json', + payload_type='json', + allowed_param=['resources'], + use_cache=False + ) + + @property + def set_delivery_device(self): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_delivery_device + :allowed_param:'device' + """ + return bind_api( + api=self, + path='/account/update_delivery_device.json', + method='POST', + allowed_param=['device'], + payload_type='user', + require_auth=True + ) + + @property + def update_profile_colors(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/account/update_profile_colors + :allowed_param:'profile_background_color', 'profile_text_color', + 'profile_link_color', 'profile_sidebar_fill_color', + 'profile_sidebar_border_color'], + """ + return bind_api( + api=self, + path='/account/update_profile_colors.json', + method='POST', + payload_type='user', + allowed_param=['profile_background_color', 'profile_text_color', + 'profile_link_color', 'profile_sidebar_fill_color', + 'profile_sidebar_border_color'], + require_auth=True + ) + + def update_profile_image(self, filename, file_=None): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_image + :allowed_param:'include_entities', 'skip_status' + """ + headers, post_data = API._pack_image(filename, 700, f=file_) + return bind_api( + api=self, + path='/account/update_profile_image.json', + method='POST', + payload_type='user', + allowed_param=['include_entities', 'skip_status'], + require_auth=True + )(self, post_data=post_data, headers=headers) + + def update_profile_background_image(self, filename, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_background_image + :allowed_param:'tile', 'include_entities', 'skip_status', 'use' + """ + f = kargs.pop('file', None) + headers, post_data = API._pack_image(filename, 800, f=f) + bind_api( + api=self, + path='/account/update_profile_background_image.json', + method='POST', + payload_type='user', + allowed_param=['tile', 'include_entities', 'skip_status', 'use'], + require_auth=True + )(post_data=post_data, headers=headers) + + def update_profile_banner(self, filename, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_banner + :allowed_param:'width', 'height', 'offset_left', 'offset_right' + """ + f = kargs.pop('file', None) + headers, post_data = API._pack_image(filename, 700, form_field="banner", f=f) + bind_api( + api=self, + path='/account/update_profile_banner.json', + method='POST', + allowed_param=['width', 'height', 'offset_left', 'offset_right'], + require_auth=True + )(post_data=post_data, headers=headers) + + @property + def update_profile(self): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile + :allowed_param:'name', 'url', 'location', 'description' + """ + return bind_api( + api=self, + path='/account/update_profile.json', + method='POST', + payload_type='user', + allowed_param=['name', 'url', 'location', 'description'], + require_auth=True + ) + + @property + def favorites(self): + """ :reference: https://dev.twitter.com/rest/reference/get/favorites/list + :allowed_param:'screen_name', 'user_id', 'max_id', 'count', 'since_id', 'max_id' + """ + return bind_api( + api=self, + path='/favorites/list.json', + payload_type='status', payload_list=True, + allowed_param=['screen_name', 'user_id', 'max_id', 'count', 'since_id', 'max_id'] + ) + + @property + def create_favorite(self): + """ :reference:https://dev.twitter.com/rest/reference/post/favorites/create + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/favorites/create.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def destroy_favorite(self): + """ :reference: https://dev.twitter.com/rest/reference/post/favorites/destroy + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/favorites/destroy.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_block(self): + """ :reference: https://dev.twitter.com/rest/reference/post/blocks/create + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/blocks/create.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def destroy_block(self): + """ :reference: https://dev.twitter.com/rest/reference/post/blocks/destroy + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/blocks/destroy.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def blocks(self): + """ :reference: https://dev.twitter.com/rest/reference/get/blocks/list + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/blocks/list.json', + payload_type='user', payload_list=True, + allowed_param=['cursor'], + require_auth=True + ) + + @property + def blocks_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/blocks/ids """ + return bind_api( + api=self, + path='/blocks/ids.json', + payload_type='json', + require_auth=True + ) + + @property + def report_spam(self): + """ :reference: https://dev.twitter.com/rest/reference/post/users/report_spam + :allowed_param:'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/users/report_spam.json', + method='POST', + payload_type='user', + allowed_param=['user_id', 'screen_name'], + require_auth=True + ) + + @property + def saved_searches(self): + """ :reference: https://dev.twitter.com/rest/reference/get/saved_searches/show/%3Aid """ + return bind_api( + api=self, + path='/saved_searches/list.json', + payload_type='saved_search', payload_list=True, + require_auth=True + ) + + @property + def get_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/get/saved_searches/show/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/saved_searches/show/{id}.json', + payload_type='saved_search', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/post/saved_searches/create + :allowed_param:'query' + """ + return bind_api( + api=self, + path='/saved_searches/create.json', + method='POST', + payload_type='saved_search', + allowed_param=['query'], + require_auth=True + ) + + @property + def destroy_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/post/saved_searches/destroy/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/saved_searches/destroy/{id}.json', + method='POST', + payload_type='saved_search', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/create + :allowed_param:'name', 'mode', 'description' + """ + return bind_api( + api=self, + path='/lists/create.json', + method='POST', + payload_type='list', + allowed_param=['name', 'mode', 'description'], + require_auth=True + ) + + @property + def destroy_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/destroy + :allowed_param:'owner_screen_name', 'owner_id', 'list_id', 'slug' + """ + return bind_api( + api=self, + path='/lists/destroy.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'owner_id', 'list_id', 'slug'], + require_auth=True + ) + + @property + def update_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/update + :allowed_param: list_id', 'slug', 'name', 'mode', 'description', 'owner_screen_name', 'owner_id' + """ + return bind_api( + api=self, + path='/lists/update.json', + method='POST', + payload_type='list', + allowed_param=['list_id', 'slug', 'name', 'mode', 'description', 'owner_screen_name', 'owner_id'], + require_auth=True + ) + + @property + def lists_all(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/list + :allowed_param:'screen_name', 'user_id' + """ + return bind_api( + api=self, + path='/lists/list.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id'], + require_auth=True + ) + + @property + def lists_memberships(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/memberships + :allowed_param:'screen_name', 'user_id', 'filter_to_owned_lists', 'cursor' + """ + return bind_api( + api=self, + path='/lists/memberships.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id', 'filter_to_owned_lists', 'cursor'], + require_auth=True + ) + + @property + def lists_subscriptions(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/subscriptions + :allowed_param:'screen_name', 'user_id', 'cursor' + """ + return bind_api( + api=self, + path='/lists/subscriptions.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id', 'cursor'], + require_auth=True + ) + + @property + def list_timeline(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/statuses + :allowed_param:'owner_screen_name', 'slug', 'owner_id', 'list_id', + 'since_id', 'max_id', 'count', 'include_rts + """ + return bind_api( + api=self, + path='/lists/statuses.json', + payload_type='status', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id', 'since_id', 'max_id', 'count', + 'include_rts'] + ) + + @property + def get_list(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/show + :allowed_param:'owner_screen_name', 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/show.json', + payload_type='list', + allowed_param=['owner_screen_name', 'owner_id', 'slug', 'list_id'] + ) + + @property + def add_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/create + :allowed_param:'screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/members/create.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id'], + require_auth=True + ) + + @property + def remove_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/destroy + :allowed_param:'screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/members/destroy.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id'], + require_auth=True + ) + + def add_list_members(self, screen_name=None, user_id=None, slug=None, + list_id=None, owner_id=None, owner_screen_name=None): + """ Perform bulk add of list members from user ID or screenname """ + return self._add_list_members(list_to_csv(screen_name), + list_to_csv(user_id), + slug, list_id, owner_id, + owner_screen_name) + + @property + def _add_list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/create_all + :allowed_param:'screen_name', 'user_id', 'slug', 'list_id', + 'owner_id', 'owner_screen_name' + + """ + return bind_api( + api=self, + path='/lists/members/create_all.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'slug', 'list_id', + 'owner_id', 'owner_screen_name'], + require_auth=True + ) + + def remove_list_members(self, screen_name=None, user_id=None, slug=None, + list_id=None, owner_id=None, owner_screen_name=None): + """ Perform bulk remove of list members from user ID or screenname """ + return self._remove_list_members(list_to_csv(screen_name), + list_to_csv(user_id), + slug, list_id, owner_id, + owner_screen_name) + + @property + def _remove_list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/destroy_all + :allowed_param:'screen_name', 'user_id', 'slug', 'list_id', + 'owner_id', 'owner_screen_name' + + """ + return bind_api( + api=self, + path='/lists/members/destroy_all.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'slug', 'list_id', + 'owner_id', 'owner_screen_name'], + require_auth=True + ) + + @property + def list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/members + :allowed_param:'owner_screen_name', 'slug', 'list_id', + 'owner_id', 'cursor + """ + return bind_api( + api=self, + path='/lists/members.json', + payload_type='user', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'list_id', + 'owner_id', 'cursor'] + ) + + @property + def show_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/members/show + :allowed_param:'list_id', 'slug', 'user_id', 'screen_name', + 'owner_screen_name', 'owner_id + """ + return bind_api( + api=self, + path='/lists/members/show.json', + payload_type='user', + allowed_param=['list_id', 'slug', 'user_id', 'screen_name', + 'owner_screen_name', 'owner_id'] + ) + + @property + def subscribe_list(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/subscribers/create + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id' + """ + return bind_api( + api=self, + path='/lists/subscribers/create.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id'], + require_auth=True + ) + + @property + def unsubscribe_list(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/subscribers/destroy + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id' + """ + return bind_api( + api=self, + path='/lists/subscribers/destroy.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id'], + require_auth=True + ) + + @property + def list_subscribers(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/subscribers + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id', 'cursor + """ + return bind_api( + api=self, + path='/lists/subscribers.json', + payload_type='user', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id', 'cursor'] + ) + + @property + def show_list_subscriber(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/subscribers/show + :allowed_param:'owner_screen_name', 'slug', 'screen_name', + 'owner_id', 'list_id', 'user_id + """ + return bind_api( + api=self, + path='/lists/subscribers/show.json', + payload_type='user', + allowed_param=['owner_screen_name', 'slug', 'screen_name', + 'owner_id', 'list_id', 'user_id'] + ) + + @property + def trends_available(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/available """ + return bind_api( + api=self, + path='/trends/available.json', + payload_type='json' + ) + + @property + def trends_place(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/place + :allowed_param:'id', 'exclude' + """ + return bind_api( + api=self, + path='/trends/place.json', + payload_type='json', + allowed_param=['id', 'exclude'] + ) + + @property + def trends_closest(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/closest + :allowed_param:'lat', 'long' + """ + return bind_api( + api=self, + path='/trends/closest.json', + payload_type='json', + allowed_param=['lat', 'long'] + ) + + @property + def search(self): + """ :reference: https://dev.twitter.com/rest/reference/get/search/tweets + :allowed_param:'q', 'lang', 'locale', 'since_id', 'geocode', + 'max_id', 'since', 'until', 'result_type', 'count', + 'include_entities', 'from', 'to', 'source'] + """ + return bind_api( + api=self, + path='/search/tweets.json', + payload_type='search_results', + allowed_param=['q', 'lang', 'locale', 'since_id', 'geocode', + 'max_id', 'since', 'until', 'result_type', + 'count', 'include_entities', 'from', + 'to', 'source'] + ) + + @property + def reverse_geocode(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/reverse_geocode + :allowed_param:'lat', 'long', 'accuracy', 'granularity', 'max_results' + """ + return bind_api( + api=self, + path='/geo/reverse_geocode.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'accuracy', 'granularity', + 'max_results'] + ) + + @property + def geo_id(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/id/%3Aplace_id + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/geo/id/{id}.json', + payload_type='place', + allowed_param=['id'] + ) + + @property + def geo_search(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/geo/search + :allowed_param:'lat', 'long', 'query', 'ip', 'granularity', + 'accuracy', 'max_results', 'contained_within + + """ + return bind_api( + api=self, + path='/geo/search.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'query', 'ip', 'granularity', + 'accuracy', 'max_results', 'contained_within'] + ) + + @property + def geo_similar_places(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/similar_places + :allowed_param:'lat', 'long', 'name', 'contained_within' + """ + return bind_api( + api=self, + path='/geo/similar_places.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'name', 'contained_within'] + ) + + @property + def supported_languages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/help/languages """ + return bind_api( + api=self, + path='/help/languages.json', + payload_type='json', + require_auth=True + ) + + @property + def configuration(self): + """ :reference: https://dev.twitter.com/rest/reference/get/help/configuration """ + return bind_api( + api=self, + path='/help/configuration.json', + payload_type='json', + require_auth=True + ) + + """ Internal use only """ + + @staticmethod + def _pack_image(filename, max_size, form_field="image", f=None): + """Pack image from file into multipart-formdata post body""" + # image must be less than 700kb in size + if f is None: + try: + if os.path.getsize(filename) > (max_size * 1024): + raise TweepError('File is too big, must be less than %skb.' % max_size) + except os.error as e: + raise TweepError('Unable to access file: %s' % e.strerror) + + # build the mulitpart-formdata body + fp = open(filename, 'rb') + else: + f.seek(0, 2) # Seek to end of file + if f.tell() > (max_size * 1024): + raise TweepError('File is too big, must be less than %skb.' % max_size) + f.seek(0) # Reset to beginning of file + fp = f + + # image must be gif, jpeg, or png + file_type = mimetypes.guess_type(filename) + if file_type is None: + raise TweepError('Could not determine file type') + file_type = file_type[0] + if file_type not in ['image/gif', 'image/jpeg', 'image/png']: + raise TweepError('Invalid file type for image: %s' % file_type) + + if isinstance(filename, six.text_type): + filename = filename.encode("utf-8") + + BOUNDARY = b'Tw3ePy' + body = list() + body.append(b'--' + BOUNDARY) + body.append('Content-Disposition: form-data; name="{0}";' + ' filename="{1}"'.format(form_field, filename) + .encode('utf-8')) + body.append('Content-Type: {0}'.format(file_type).encode('utf-8')) + body.append(b'') + body.append(fp.read()) + body.append(b'--' + BOUNDARY + b'--') + body.append(b'') + fp.close() + body = b'\r\n'.join(body) + + # build headers + headers = { + 'Content-Type': 'multipart/form-data; boundary=Tw3ePy', + 'Content-Length': str(len(body)) + } + + return headers, body diff --git a/apprise/plugins/NotifyTwitter/tweepy/auth.py b/apprise/plugins/NotifyTwitter/tweepy/auth.py new file mode 100644 index 00000000..b450e310 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/auth.py @@ -0,0 +1,178 @@ +from __future__ import print_function + +import six +import logging + +from .error import TweepError +from .api import API +import requests +from requests_oauthlib import OAuth1Session, OAuth1 +from requests.auth import AuthBase +from six.moves.urllib.parse import parse_qs + +WARNING_MESSAGE = """Warning! Due to a Twitter API bug, signin_with_twitter +and access_type don't always play nice together. Details +https://dev.twitter.com/discussions/21281""" + + +class AuthHandler(object): + + def apply_auth(self, url, method, headers, parameters): + """Apply authentication headers to request""" + raise NotImplementedError + + def get_username(self): + """Return the username of the authenticated user""" + raise NotImplementedError + + +class OAuthHandler(AuthHandler): + """OAuth authentication handler""" + OAUTH_HOST = 'api.twitter.com' + OAUTH_ROOT = '/oauth/' + + def __init__(self, consumer_key, consumer_secret, callback=None): + if type(consumer_key) == six.text_type: + consumer_key = consumer_key.encode('ascii') + + if type(consumer_secret) == six.text_type: + consumer_secret = consumer_secret.encode('ascii') + + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.access_token = None + self.access_token_secret = None + self.callback = callback + self.username = None + self.oauth = OAuth1Session(consumer_key, + client_secret=consumer_secret, + callback_uri=self.callback) + + def _get_oauth_url(self, endpoint): + return 'https://' + self.OAUTH_HOST + self.OAUTH_ROOT + endpoint + + def apply_auth(self): + return OAuth1(self.consumer_key, + client_secret=self.consumer_secret, + resource_owner_key=self.access_token, + resource_owner_secret=self.access_token_secret, + decoding=None) + + def _get_request_token(self, access_type=None): + try: + url = self._get_oauth_url('request_token') + if access_type: + url += '?x_auth_access_type=%s' % access_type + return self.oauth.fetch_request_token(url) + except Exception as e: + raise TweepError(e) + + def set_access_token(self, key, secret): + self.access_token = key + self.access_token_secret = secret + + def get_authorization_url(self, + signin_with_twitter=False, + access_type=None): + """Get the authorization URL to redirect the user""" + try: + if signin_with_twitter: + url = self._get_oauth_url('authenticate') + if access_type: + logging.warning(WARNING_MESSAGE) + else: + url = self._get_oauth_url('authorize') + self.request_token = self._get_request_token(access_type=access_type) + return self.oauth.authorization_url(url) + except Exception as e: + raise TweepError(e) + + def get_access_token(self, verifier=None): + """ + After user has authorized the request token, get access token + with user supplied verifier. + """ + try: + url = self._get_oauth_url('access_token') + self.oauth = OAuth1Session(self.consumer_key, + client_secret=self.consumer_secret, + resource_owner_key=self.request_token['oauth_token'], + resource_owner_secret=self.request_token['oauth_token_secret'], + verifier=verifier, callback_uri=self.callback) + resp = self.oauth.fetch_access_token(url) + self.access_token = resp['oauth_token'] + self.access_token_secret = resp['oauth_token_secret'] + return self.access_token, self.access_token_secret + except Exception as e: + raise TweepError(e) + + def get_xauth_access_token(self, username, password): + """ + Get an access token from an username and password combination. + In order to get this working you need to create an app at + http://twitter.com/apps, after that send a mail to api@twitter.com + and request activation of xAuth for it. + """ + try: + url = self._get_oauth_url('access_token') + oauth = OAuth1(self.consumer_key, + client_secret=self.consumer_secret) + r = requests.post(url=url, + auth=oauth, + headers={'x_auth_mode': 'client_auth', + 'x_auth_username': username, + 'x_auth_password': password}) + + credentials = parse_qs(r.content) + return credentials.get('oauth_token')[0], credentials.get('oauth_token_secret')[0] + except Exception as e: + raise TweepError(e) + + def get_username(self): + if self.username is None: + api = API(self) + user = api.verify_credentials() + if user: + self.username = user.screen_name + else: + raise TweepError('Unable to get username,' + ' invalid oauth token!') + return self.username + + +class OAuth2Bearer(AuthBase): + def __init__(self, bearer_token): + self.bearer_token = bearer_token + + def __call__(self, request): + request.headers['Authorization'] = 'Bearer ' + self.bearer_token + return request + + +class AppAuthHandler(AuthHandler): + """Application-only authentication handler""" + + OAUTH_HOST = 'api.twitter.com' + OAUTH_ROOT = '/oauth2/' + + def __init__(self, consumer_key, consumer_secret): + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self._bearer_token = '' + + resp = requests.post(self._get_oauth_url('token'), + auth=(self.consumer_key, + self.consumer_secret), + data={'grant_type': 'client_credentials'}) + data = resp.json() + if data.get('token_type') != 'bearer': + raise TweepError('Expected token_type to equal "bearer", ' + 'but got %s instead' % data.get('token_type')) + + self._bearer_token = data['access_token'] + + def _get_oauth_url(self, endpoint): + return 'https://' + self.OAUTH_HOST + self.OAUTH_ROOT + endpoint + + def apply_auth(self): + return OAuth2Bearer(self._bearer_token) diff --git a/apprise/plugins/NotifyTwitter/tweepy/binder.py b/apprise/plugins/NotifyTwitter/tweepy/binder.py new file mode 100644 index 00000000..42c3ad8f --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/binder.py @@ -0,0 +1,256 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +import time +import re + +from six.moves.urllib.parse import quote +import requests + +import logging + +from .error import TweepError, RateLimitError, is_rate_limit_error_message +from .utils import convert_to_utf8_str +from .models import Model + + +re_path_template = re.compile('{\w+}') + +log = logging.getLogger('tweepy.binder') + +def bind_api(**config): + + class APIMethod(object): + + api = config['api'] + path = config['path'] + payload_type = config.get('payload_type', None) + payload_list = config.get('payload_list', False) + allowed_param = config.get('allowed_param', []) + method = config.get('method', 'GET') + require_auth = config.get('require_auth', False) + search_api = config.get('search_api', False) + upload_api = config.get('upload_api', False) + use_cache = config.get('use_cache', True) + session = requests.Session() + + def __init__(self, args, kwargs): + api = self.api + # If authentication is required and no credentials + # are provided, throw an error. + if self.require_auth and not api.auth: + raise TweepError('Authentication required!') + + self.post_data = kwargs.pop('post_data', None) + self.retry_count = kwargs.pop('retry_count', + api.retry_count) + self.retry_delay = kwargs.pop('retry_delay', + api.retry_delay) + self.retry_errors = kwargs.pop('retry_errors', + api.retry_errors) + self.wait_on_rate_limit = kwargs.pop('wait_on_rate_limit', + api.wait_on_rate_limit) + self.wait_on_rate_limit_notify = kwargs.pop('wait_on_rate_limit_notify', + api.wait_on_rate_limit_notify) + self.parser = kwargs.pop('parser', api.parser) + self.session.headers = kwargs.pop('headers', {}) + self.build_parameters(args, kwargs) + + # Pick correct URL root to use + if self.search_api: + self.api_root = api.search_root + elif self.upload_api: + self.api_root = api.upload_root + else: + self.api_root = api.api_root + + # Perform any path variable substitution + self.build_path() + + if self.search_api: + self.host = api.search_host + elif self.upload_api: + self.host = api.upload_host + else: + self.host = api.host + + # Manually set Host header to fix an issue in python 2.5 + # or older where Host is set including the 443 port. + # This causes Twitter to issue 301 redirect. + # See Issue https://github.com/tweepy/tweepy/issues/12 + self.session.headers['Host'] = self.host + # Monitoring rate limits + self._remaining_calls = None + self._reset_time = None + + def build_parameters(self, args, kwargs): + self.session.params = {} + for idx, arg in enumerate(args): + if arg is None: + continue + try: + self.session.params[self.allowed_param[idx]] = convert_to_utf8_str(arg) + except IndexError: + raise TweepError('Too many parameters supplied!') + + for k, arg in kwargs.items(): + if arg is None: + continue + if k in self.session.params: + raise TweepError('Multiple values for parameter %s supplied!' % k) + + self.session.params[k] = convert_to_utf8_str(arg) + + log.info("PARAMS: %r", self.session.params) + + def build_path(self): + for variable in re_path_template.findall(self.path): + name = variable.strip('{}') + + if name == 'user' and 'user' not in self.session.params and self.api.auth: + # No 'user' parameter provided, fetch it from Auth instead. + value = self.api.auth.get_username() + else: + try: + value = quote(self.session.params[name]) + except KeyError: + raise TweepError('No parameter value found for path variable: %s' % name) + del self.session.params[name] + + self.path = self.path.replace(variable, value) + + def execute(self): + self.api.cached_result = False + + # Build the request URL + url = self.api_root + self.path + full_url = 'https://' + self.host + url + + # Query the cache if one is available + # and this request uses a GET method. + if self.use_cache and self.api.cache and self.method == 'GET': + cache_result = self.api.cache.get(url) + # if cache result found and not expired, return it + if cache_result: + # must restore api reference + if isinstance(cache_result, list): + for result in cache_result: + if isinstance(result, Model): + result._api = self.api + else: + if isinstance(cache_result, Model): + cache_result._api = self.api + self.api.cached_result = True + return cache_result + + # Continue attempting request until successful + # or maximum number of retries is reached. + retries_performed = 0 + while retries_performed < self.retry_count + 1: + # handle running out of api calls + if self.wait_on_rate_limit: + if self._reset_time is not None: + if self._remaining_calls is not None: + if self._remaining_calls < 1: + sleep_time = self._reset_time - int(time.time()) + if sleep_time > 0: + if self.wait_on_rate_limit_notify: + print("Rate limit reached. Sleeping for:", sleep_time) + time.sleep(sleep_time + 5) # sleep for few extra sec + + # if self.wait_on_rate_limit and self._reset_time is not None and \ + # self._remaining_calls is not None and self._remaining_calls < 1: + # sleep_time = self._reset_time - int(time.time()) + # if sleep_time > 0: + # if self.wait_on_rate_limit_notify: + # print("Rate limit reached. Sleeping for: " + str(sleep_time)) + # time.sleep(sleep_time + 5) # sleep for few extra sec + + # Apply authentication + if self.api.auth: + auth = self.api.auth.apply_auth() + + # Request compression if configured + if self.api.compression: + self.session.headers['Accept-encoding'] = 'gzip' + + # Execute request + try: + resp = self.session.request(self.method, + full_url, + data=self.post_data, + timeout=self.api.timeout, + auth=auth, + proxies=self.api.proxy) + except Exception as e: + raise TweepError('Failed to send request: %s' % e) + rem_calls = resp.headers.get('x-rate-limit-remaining') + if rem_calls is not None: + self._remaining_calls = int(rem_calls) + elif isinstance(self._remaining_calls, int): + self._remaining_calls -= 1 + reset_time = resp.headers.get('x-rate-limit-reset') + if reset_time is not None: + self._reset_time = int(reset_time) + if self.wait_on_rate_limit and self._remaining_calls == 0 and ( + # if ran out of calls before waiting switching retry last call + resp.status_code == 429 or resp.status_code == 420): + continue + retry_delay = self.retry_delay + # Exit request loop if non-retry error code + if resp.status_code == 200: + break + elif (resp.status_code == 429 or resp.status_code == 420) and self.wait_on_rate_limit: + if 'retry-after' in resp.headers: + retry_delay = float(resp.headers['retry-after']) + elif self.retry_errors and resp.status_code not in self.retry_errors: + break + + # Sleep before retrying request again + time.sleep(retry_delay) + retries_performed += 1 + + # If an error was returned, throw an exception + self.api.last_response = resp + if resp.status_code and not 200 <= resp.status_code < 300: + try: + error_msg, api_error_code = \ + self.parser.parse_error(resp.text) + except Exception: + error_msg = "Twitter error response: status code = %s" % resp.status_code + api_error_code = None + + if is_rate_limit_error_message(error_msg): + raise RateLimitError(error_msg, resp) + else: + raise TweepError(error_msg, resp, api_code=api_error_code) + + # Parse the response payload + result = self.parser.parse(self, resp.text) + + # Store result into cache if one is available. + if self.use_cache and self.api.cache and self.method == 'GET' and result: + self.api.cache.store(url, result) + + return result + + def _call(*args, **kwargs): + method = APIMethod(args, kwargs) + if kwargs.get('create'): + return method + else: + return method.execute() + + # Set pagination mode + if 'cursor' in APIMethod.allowed_param: + _call.pagination_mode = 'cursor' + elif 'max_id' in APIMethod.allowed_param: + if 'since_id' in APIMethod.allowed_param: + _call.pagination_mode = 'id' + elif 'page' in APIMethod.allowed_param: + _call.pagination_mode = 'page' + + return _call diff --git a/apprise/plugins/NotifyTwitter/tweepy/cache.py b/apprise/plugins/NotifyTwitter/tweepy/cache.py new file mode 100644 index 00000000..1d6cb562 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/cache.py @@ -0,0 +1,435 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +import time +import datetime +import threading +import os + +try: + import cPickle as pickle +except ImportError: + import pickle + +try: + import hashlib +except ImportError: + # python 2.4 + import md5 as hashlib + +try: + import fcntl +except ImportError: + # Probably on a windows system + # TODO: use win32file + pass + + +class Cache(object): + """Cache interface""" + + def __init__(self, timeout=60): + """Initialize the cache + timeout: number of seconds to keep a cached entry + """ + self.timeout = timeout + + def store(self, key, value): + """Add new record to cache + key: entry key + value: data of entry + """ + raise NotImplementedError + + def get(self, key, timeout=None): + """Get cached entry if exists and not expired + key: which entry to get + timeout: override timeout with this value [optional] + """ + raise NotImplementedError + + def count(self): + """Get count of entries currently stored in cache""" + raise NotImplementedError + + def cleanup(self): + """Delete any expired entries in cache.""" + raise NotImplementedError + + def flush(self): + """Delete all cached entries""" + raise NotImplementedError + + +class MemoryCache(Cache): + """In-memory cache""" + + def __init__(self, timeout=60): + Cache.__init__(self, timeout) + self._entries = {} + self.lock = threading.Lock() + + def __getstate__(self): + # pickle + return {'entries': self._entries, 'timeout': self.timeout} + + def __setstate__(self, state): + # unpickle + self.lock = threading.Lock() + self._entries = state['entries'] + self.timeout = state['timeout'] + + def _is_expired(self, entry, timeout): + return timeout > 0 and (time.time() - entry[0]) >= timeout + + def store(self, key, value): + self.lock.acquire() + self._entries[key] = (time.time(), value) + self.lock.release() + + def get(self, key, timeout=None): + self.lock.acquire() + try: + # check to see if we have this key + entry = self._entries.get(key) + if not entry: + # no hit, return nothing + return None + + # use provided timeout in arguments if provided + # otherwise use the one provided during init. + if timeout is None: + timeout = self.timeout + + # make sure entry is not expired + if self._is_expired(entry, timeout): + # entry expired, delete and return nothing + del self._entries[key] + return None + + # entry found and not expired, return it + return entry[1] + finally: + self.lock.release() + + def count(self): + return len(self._entries) + + def cleanup(self): + self.lock.acquire() + try: + for k, v in dict(self._entries).items(): + if self._is_expired(v, self.timeout): + del self._entries[k] + finally: + self.lock.release() + + def flush(self): + self.lock.acquire() + self._entries.clear() + self.lock.release() + + +class FileCache(Cache): + """File-based cache""" + + # locks used to make cache thread-safe + cache_locks = {} + + def __init__(self, cache_dir, timeout=60): + Cache.__init__(self, timeout) + if os.path.exists(cache_dir) is False: + os.mkdir(cache_dir) + self.cache_dir = cache_dir + if cache_dir in FileCache.cache_locks: + self.lock = FileCache.cache_locks[cache_dir] + else: + self.lock = threading.Lock() + FileCache.cache_locks[cache_dir] = self.lock + + if os.name == 'posix': + self._lock_file = self._lock_file_posix + self._unlock_file = self._unlock_file_posix + elif os.name == 'nt': + self._lock_file = self._lock_file_win32 + self._unlock_file = self._unlock_file_win32 + else: + print('Warning! FileCache locking not supported on this system!') + self._lock_file = self._lock_file_dummy + self._unlock_file = self._unlock_file_dummy + + def _get_path(self, key): + md5 = hashlib.md5() + md5.update(key.encode('utf-8')) + return os.path.join(self.cache_dir, md5.hexdigest()) + + def _lock_file_dummy(self, path, exclusive=True): + return None + + def _unlock_file_dummy(self, lock): + return + + def _lock_file_posix(self, path, exclusive=True): + lock_path = path + '.lock' + if exclusive is True: + f_lock = open(lock_path, 'w') + fcntl.lockf(f_lock, fcntl.LOCK_EX) + else: + f_lock = open(lock_path, 'r') + fcntl.lockf(f_lock, fcntl.LOCK_SH) + if os.path.exists(lock_path) is False: + f_lock.close() + return None + return f_lock + + def _unlock_file_posix(self, lock): + lock.close() + + def _lock_file_win32(self, path, exclusive=True): + # TODO: implement + return None + + def _unlock_file_win32(self, lock): + # TODO: implement + return + + def _delete_file(self, path): + os.remove(path) + if os.path.exists(path + '.lock'): + os.remove(path + '.lock') + + def store(self, key, value): + path = self._get_path(key) + self.lock.acquire() + try: + # acquire lock and open file + f_lock = self._lock_file(path) + datafile = open(path, 'wb') + + # write data + pickle.dump((time.time(), value), datafile) + + # close and unlock file + datafile.close() + self._unlock_file(f_lock) + finally: + self.lock.release() + + def get(self, key, timeout=None): + return self._get(self._get_path(key), timeout) + + def _get(self, path, timeout): + if os.path.exists(path) is False: + # no record + return None + self.lock.acquire() + try: + # acquire lock and open + f_lock = self._lock_file(path, False) + datafile = open(path, 'rb') + + # read pickled object + created_time, value = pickle.load(datafile) + datafile.close() + + # check if value is expired + if timeout is None: + timeout = self.timeout + if timeout > 0: + if (time.time() - created_time) >= timeout: + # expired! delete from cache + value = None + self._delete_file(path) + + # unlock and return result + self._unlock_file(f_lock) + return value + finally: + self.lock.release() + + def count(self): + c = 0 + for entry in os.listdir(self.cache_dir): + if entry.endswith('.lock'): + continue + c += 1 + return c + + def cleanup(self): + for entry in os.listdir(self.cache_dir): + if entry.endswith('.lock'): + continue + self._get(os.path.join(self.cache_dir, entry), None) + + def flush(self): + for entry in os.listdir(self.cache_dir): + if entry.endswith('.lock'): + continue + self._delete_file(os.path.join(self.cache_dir, entry)) + + +class MemCacheCache(Cache): + """Cache interface""" + + def __init__(self, client, timeout=60): + """Initialize the cache + client: The memcache client + timeout: number of seconds to keep a cached entry + """ + self.client = client + self.timeout = timeout + + def store(self, key, value): + """Add new record to cache + key: entry key + value: data of entry + """ + self.client.set(key, value, time=self.timeout) + + def get(self, key, timeout=None): + """Get cached entry if exists and not expired + key: which entry to get + timeout: override timeout with this value [optional]. + DOES NOT WORK HERE + """ + return self.client.get(key) + + def count(self): + """Get count of entries currently stored in cache. RETURN 0""" + raise NotImplementedError + + def cleanup(self): + """Delete any expired entries in cache. NO-OP""" + raise NotImplementedError + + def flush(self): + """Delete all cached entries. NO-OP""" + raise NotImplementedError + + +class RedisCache(Cache): + """Cache running in a redis server""" + + def __init__(self, client, + timeout=60, + keys_container='tweepy:keys', + pre_identifier='tweepy:'): + Cache.__init__(self, timeout) + self.client = client + self.keys_container = keys_container + self.pre_identifier = pre_identifier + + def _is_expired(self, entry, timeout): + # Returns true if the entry has expired + return timeout > 0 and (time.time() - entry[0]) >= timeout + + def store(self, key, value): + """Store the key, value pair in our redis server""" + # Prepend tweepy to our key, + # this makes it easier to identify tweepy keys in our redis server + key = self.pre_identifier + key + # Get a pipe (to execute several redis commands in one step) + pipe = self.client.pipeline() + # Set our values in a redis hash (similar to python dict) + pipe.set(key, pickle.dumps((time.time(), value))) + # Set the expiration + pipe.expire(key, self.timeout) + # Add the key to a set containing all the keys + pipe.sadd(self.keys_container, key) + # Execute the instructions in the redis server + pipe.execute() + + def get(self, key, timeout=None): + """Given a key, returns an element from the redis table""" + key = self.pre_identifier + key + # Check to see if we have this key + unpickled_entry = self.client.get(key) + if not unpickled_entry: + # No hit, return nothing + return None + + entry = pickle.loads(unpickled_entry) + # Use provided timeout in arguments if provided + # otherwise use the one provided during init. + if timeout is None: + timeout = self.timeout + + # Make sure entry is not expired + if self._is_expired(entry, timeout): + # entry expired, delete and return nothing + self.delete_entry(key) + return None + # entry found and not expired, return it + return entry[1] + + def count(self): + """Note: This is not very efficient, + since it retreives all the keys from the redis + server to know how many keys we have""" + return len(self.client.smembers(self.keys_container)) + + def delete_entry(self, key): + """Delete an object from the redis table""" + pipe = self.client.pipeline() + pipe.srem(self.keys_container, key) + pipe.delete(key) + pipe.execute() + + def cleanup(self): + """Cleanup all the expired keys""" + keys = self.client.smembers(self.keys_container) + for key in keys: + entry = self.client.get(key) + if entry: + entry = pickle.loads(entry) + if self._is_expired(entry, self.timeout): + self.delete_entry(key) + + def flush(self): + """Delete all entries from the cache""" + keys = self.client.smembers(self.keys_container) + for key in keys: + self.delete_entry(key) + + +class MongodbCache(Cache): + """A simple pickle-based MongoDB cache sytem.""" + + def __init__(self, db, timeout=3600, collection='tweepy_cache'): + """Should receive a "database" cursor from pymongo.""" + Cache.__init__(self, timeout) + self.timeout = timeout + self.col = db[collection] + self.col.create_index('created', expireAfterSeconds=timeout) + + def store(self, key, value): + from bson.binary import Binary + + now = datetime.datetime.utcnow() + blob = Binary(pickle.dumps(value)) + + self.col.insert({'created': now, '_id': key, 'value': blob}) + + def get(self, key, timeout=None): + if timeout: + raise NotImplementedError + obj = self.col.find_one({'_id': key}) + if obj: + return pickle.loads(obj['value']) + + def count(self): + return self.col.find({}).count() + + def delete_entry(self, key): + return self.col.remove({'_id': key}) + + def cleanup(self): + """MongoDB will automatically clear expired keys.""" + pass + + def flush(self): + self.col.drop() + self.col.create_index('created', expireAfterSeconds=self.timeout) diff --git a/apprise/plugins/NotifyTwitter/tweepy/cursor.py b/apprise/plugins/NotifyTwitter/tweepy/cursor.py new file mode 100644 index 00000000..3ab28c28 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/cursor.py @@ -0,0 +1,214 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +from .error import TweepError +from .parsers import ModelParser, RawParser + + +class Cursor(object): + """Pagination helper class""" + + def __init__(self, method, *args, **kargs): + if hasattr(method, 'pagination_mode'): + if method.pagination_mode == 'cursor': + self.iterator = CursorIterator(method, args, kargs) + elif method.pagination_mode == 'id': + self.iterator = IdIterator(method, args, kargs) + elif method.pagination_mode == 'page': + self.iterator = PageIterator(method, args, kargs) + else: + raise TweepError('Invalid pagination mode.') + else: + raise TweepError('This method does not perform pagination') + + def pages(self, limit=0): + """Return iterator for pages""" + if limit > 0: + self.iterator.limit = limit + return self.iterator + + def items(self, limit=0): + """Return iterator for items in each page""" + i = ItemIterator(self.iterator) + i.limit = limit + return i + + +class BaseIterator(object): + + def __init__(self, method, args, kargs): + self.method = method + self.args = args + self.kargs = kargs + self.limit = 0 + + def __next__(self): + return self.next() + + def next(self): + raise NotImplementedError + + def prev(self): + raise NotImplementedError + + def __iter__(self): + return self + + +class CursorIterator(BaseIterator): + + def __init__(self, method, args, kargs): + BaseIterator.__init__(self, method, args, kargs) + start_cursor = kargs.pop('cursor', None) + self.next_cursor = start_cursor or -1 + self.prev_cursor = start_cursor or 0 + self.num_tweets = 0 + + def next(self): + if self.next_cursor == 0 or (self.limit and self.num_tweets == self.limit): + raise StopIteration + data, cursors = self.method(cursor=self.next_cursor, + *self.args, + **self.kargs) + self.prev_cursor, self.next_cursor = cursors + if len(data) == 0: + raise StopIteration + self.num_tweets += 1 + return data + + def prev(self): + if self.prev_cursor == 0: + raise TweepError('Can not page back more, at first page') + data, self.next_cursor, self.prev_cursor = self.method(cursor=self.prev_cursor, + *self.args, + **self.kargs) + self.num_tweets -= 1 + return data + + +class IdIterator(BaseIterator): + + def __init__(self, method, args, kargs): + BaseIterator.__init__(self, method, args, kargs) + self.max_id = kargs.pop('max_id', None) + self.num_tweets = 0 + self.results = [] + self.model_results = [] + self.index = 0 + + def next(self): + """Fetch a set of items with IDs less than current set.""" + if self.limit and self.limit == self.num_tweets: + raise StopIteration + + if self.index >= len(self.results) - 1: + data = self.method(max_id=self.max_id, parser=RawParser(), *self.args, **self.kargs) + + if hasattr(self.method, '__self__'): + old_parser = self.method.__self__.parser + # Hack for models which expect ModelParser to be set + self.method.__self__.parser = ModelParser() + + # This is a special invocation that returns the underlying + # APIMethod class + model = ModelParser().parse(self.method(create=True), data) + if hasattr(self.method, '__self__'): + self.method.__self__.parser = old_parser + result = self.method.__self__.parser.parse(self.method(create=True), data) + else: + result = model + + if len(self.results) != 0: + self.index += 1 + self.results.append(result) + self.model_results.append(model) + else: + self.index += 1 + result = self.results[self.index] + model = self.model_results[self.index] + + if len(result) == 0: + raise StopIteration + # TODO: Make this not dependant on the parser making max_id and + # since_id available + self.max_id = model.max_id + self.num_tweets += 1 + return result + + def prev(self): + """Fetch a set of items with IDs greater than current set.""" + if self.limit and self.limit == self.num_tweets: + raise StopIteration + + self.index -= 1 + if self.index < 0: + # There's no way to fetch a set of tweets directly 'above' the + # current set + raise StopIteration + + data = self.results[self.index] + self.max_id = self.model_results[self.index].max_id + self.num_tweets += 1 + return data + + +class PageIterator(BaseIterator): + + def __init__(self, method, args, kargs): + BaseIterator.__init__(self, method, args, kargs) + self.current_page = 0 + + def next(self): + if self.limit > 0: + if self.current_page > self.limit: + raise StopIteration + + items = self.method(page=self.current_page, *self.args, **self.kargs) + if len(items) == 0: + raise StopIteration + self.current_page += 1 + return items + + def prev(self): + if self.current_page == 1: + raise TweepError('Can not page back more, at first page') + self.current_page -= 1 + return self.method(page=self.current_page, *self.args, **self.kargs) + + +class ItemIterator(BaseIterator): + + def __init__(self, page_iterator): + self.page_iterator = page_iterator + self.limit = 0 + self.current_page = None + self.page_index = -1 + self.num_tweets = 0 + + def next(self): + if self.limit > 0: + if self.num_tweets == self.limit: + raise StopIteration + if self.current_page is None or self.page_index == len(self.current_page) - 1: + # Reached end of current page, get the next page... + self.current_page = self.page_iterator.next() + self.page_index = -1 + self.page_index += 1 + self.num_tweets += 1 + return self.current_page[self.page_index] + + def prev(self): + if self.current_page is None: + raise TweepError('Can not go back more, at first page') + if self.page_index == 0: + # At the beginning of the current page, move to next... + self.current_page = self.page_iterator.prev() + self.page_index = len(self.current_page) + if self.page_index == 0: + raise TweepError('No more items') + self.page_index -= 1 + self.num_tweets -= 1 + return self.current_page[self.page_index] diff --git a/apprise/plugins/NotifyTwitter/tweepy/error.py b/apprise/plugins/NotifyTwitter/tweepy/error.py new file mode 100644 index 00000000..f7d58944 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/error.py @@ -0,0 +1,34 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +import six + +class TweepError(Exception): + """Tweepy exception""" + + def __init__(self, reason, response=None, api_code=None): + self.reason = six.text_type(reason) + self.response = response + self.api_code = api_code + Exception.__init__(self, reason) + + def __str__(self): + return self.reason + + +def is_rate_limit_error_message(message): + """Check if the supplied error message belongs to a rate limit error.""" + return isinstance(message, list) \ + and len(message) > 0 \ + and 'code' in message[0] \ + and message[0]['code'] == 88 + + +class RateLimitError(TweepError): + """Exception for Tweepy hitting the rate limit.""" + # RateLimitError has the exact same properties and inner workings + # as TweepError for backwards compatibility reasons. + pass diff --git a/apprise/plugins/NotifyTwitter/tweepy/models.py b/apprise/plugins/NotifyTwitter/tweepy/models.py new file mode 100644 index 00000000..71fefade --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/models.py @@ -0,0 +1,493 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import absolute_import, print_function + +from .utils import parse_datetime, parse_html_value, parse_a_href + + +class ResultSet(list): + """A list like object that holds results from a Twitter API query.""" + def __init__(self, max_id=None, since_id=None): + super(ResultSet, self).__init__() + self._max_id = max_id + self._since_id = since_id + + @property + def max_id(self): + if self._max_id: + return self._max_id + ids = self.ids() + # Max_id is always set to the *smallest* id, minus one, in the set + return (min(ids) - 1) if ids else None + + @property + def since_id(self): + if self._since_id: + return self._since_id + ids = self.ids() + # Since_id is always set to the *greatest* id in the set + return max(ids) if ids else None + + def ids(self): + return [item.id for item in self if hasattr(item, 'id')] + + +class Model(object): + + def __init__(self, api=None): + self._api = api + + def __getstate__(self): + # pickle + pickle = dict(self.__dict__) + try: + del pickle['_api'] # do not pickle the API reference + except KeyError: + pass + return pickle + + @classmethod + def parse(cls, api, json): + """Parse a JSON object into a model instance.""" + raise NotImplementedError + + @classmethod + def parse_list(cls, api, json_list): + """ + Parse a list of JSON objects into + a result set of model instances. + """ + results = ResultSet() + for obj in json_list: + if obj: + results.append(cls.parse(api, obj)) + return results + + def __repr__(self): + state = ['%s=%s' % (k, repr(v)) for (k, v) in vars(self).items()] + return '%s(%s)' % (self.__class__.__name__, ', '.join(state)) + + +class Status(Model): + + @classmethod + def parse(cls, api, json): + status = cls(api) + setattr(status, '_json', json) + for k, v in json.items(): + if k == 'user': + user_model = getattr(api.parser.model_factory, 'user') if api else User + user = user_model.parse(api, v) + setattr(status, 'author', user) + setattr(status, 'user', user) # DEPRECIATED + elif k == 'created_at': + setattr(status, k, parse_datetime(v)) + elif k == 'source': + if '<' in v: + setattr(status, k, parse_html_value(v)) + setattr(status, 'source_url', parse_a_href(v)) + else: + setattr(status, k, v) + setattr(status, 'source_url', None) + elif k == 'retweeted_status': + setattr(status, k, Status.parse(api, v)) + elif k == 'place': + if v is not None: + setattr(status, k, Place.parse(api, v)) + else: + setattr(status, k, None) + else: + setattr(status, k, v) + return status + + def destroy(self): + return self._api.destroy_status(self.id) + + def retweet(self): + return self._api.retweet(self.id) + + def retweets(self): + return self._api.retweets(self.id) + + def favorite(self): + return self._api.create_favorite(self.id) + + def __eq__(self, other): + if isinstance(other, Status): + return self.id == other.id + + return NotImplemented + + def __ne__(self, other): + result = self == other + + if result is NotImplemented: + return result + + return not result + + +class User(Model): + + @classmethod + def parse(cls, api, json): + user = cls(api) + setattr(user, '_json', json) + for k, v in json.items(): + if k == 'created_at': + setattr(user, k, parse_datetime(v)) + elif k == 'status': + setattr(user, k, Status.parse(api, v)) + elif k == 'following': + # twitter sets this to null if it is false + if v is True: + setattr(user, k, True) + else: + setattr(user, k, False) + else: + setattr(user, k, v) + return user + + @classmethod + def parse_list(cls, api, json_list): + if isinstance(json_list, list): + item_list = json_list + else: + item_list = json_list['users'] + + results = ResultSet() + for obj in item_list: + results.append(cls.parse(api, obj)) + return results + + def timeline(self, **kargs): + return self._api.user_timeline(user_id=self.id, **kargs) + + def friends(self, **kargs): + return self._api.friends(user_id=self.id, **kargs) + + def followers(self, **kargs): + return self._api.followers(user_id=self.id, **kargs) + + def follow(self): + self._api.create_friendship(user_id=self.id) + self.following = True + + def unfollow(self): + self._api.destroy_friendship(user_id=self.id) + self.following = False + + def lists_memberships(self, *args, **kargs): + return self._api.lists_memberships(user=self.screen_name, + *args, + **kargs) + + def lists_subscriptions(self, *args, **kargs): + return self._api.lists_subscriptions(user=self.screen_name, + *args, + **kargs) + + def lists(self, *args, **kargs): + return self._api.lists_all(user=self.screen_name, + *args, + **kargs) + + def followers_ids(self, *args, **kargs): + return self._api.followers_ids(user_id=self.id, + *args, + **kargs) + + +class DirectMessage(Model): + + @classmethod + def parse(cls, api, json): + dm = cls(api) + for k, v in json.items(): + if k == 'sender' or k == 'recipient': + setattr(dm, k, User.parse(api, v)) + elif k == 'created_at': + setattr(dm, k, parse_datetime(v)) + else: + setattr(dm, k, v) + return dm + + def destroy(self): + return self._api.destroy_direct_message(self.id) + + +class Friendship(Model): + + @classmethod + def parse(cls, api, json): + relationship = json['relationship'] + + # parse source + source = cls(api) + for k, v in relationship['source'].items(): + setattr(source, k, v) + + # parse target + target = cls(api) + for k, v in relationship['target'].items(): + setattr(target, k, v) + + return source, target + + +class Category(Model): + + @classmethod + def parse(cls, api, json): + category = cls(api) + for k, v in json.items(): + setattr(category, k, v) + return category + + +class SavedSearch(Model): + + @classmethod + def parse(cls, api, json): + ss = cls(api) + for k, v in json.items(): + if k == 'created_at': + setattr(ss, k, parse_datetime(v)) + else: + setattr(ss, k, v) + return ss + + def destroy(self): + return self._api.destroy_saved_search(self.id) + + +class SearchResults(ResultSet): + + @classmethod + def parse(cls, api, json): + metadata = json['search_metadata'] + results = SearchResults() + results.refresh_url = metadata.get('refresh_url') + results.completed_in = metadata.get('completed_in') + results.query = metadata.get('query') + results.count = metadata.get('count') + results.next_results = metadata.get('next_results') + + status_model = getattr(api.parser.model_factory, 'status') if api else Status + + for status in json['statuses']: + results.append(status_model.parse(api, status)) + return results + + +class List(Model): + + @classmethod + def parse(cls, api, json): + lst = List(api) + for k, v in json.items(): + if k == 'user': + setattr(lst, k, User.parse(api, v)) + elif k == 'created_at': + setattr(lst, k, parse_datetime(v)) + else: + setattr(lst, k, v) + return lst + + @classmethod + def parse_list(cls, api, json_list, result_set=None): + results = ResultSet() + if isinstance(json_list, dict): + json_list = json_list['lists'] + for obj in json_list: + results.append(cls.parse(api, obj)) + return results + + def update(self, **kargs): + return self._api.update_list(self.slug, **kargs) + + def destroy(self): + return self._api.destroy_list(self.slug) + + def timeline(self, **kargs): + return self._api.list_timeline(self.user.screen_name, + self.slug, + **kargs) + + def add_member(self, id): + return self._api.add_list_member(self.slug, id) + + def remove_member(self, id): + return self._api.remove_list_member(self.slug, id) + + def members(self, **kargs): + return self._api.list_members(self.user.screen_name, + self.slug, + **kargs) + + def is_member(self, id): + return self._api.is_list_member(self.user.screen_name, + self.slug, + id) + + def subscribe(self): + return self._api.subscribe_list(self.user.screen_name, self.slug) + + def unsubscribe(self): + return self._api.unsubscribe_list(self.user.screen_name, self.slug) + + def subscribers(self, **kargs): + return self._api.list_subscribers(self.user.screen_name, + self.slug, + **kargs) + + def is_subscribed(self, id): + return self._api.is_subscribed_list(self.user.screen_name, + self.slug, + id) + + +class Relation(Model): + @classmethod + def parse(cls, api, json): + result = cls(api) + for k, v in json.items(): + if k == 'value' and json['kind'] in ['Tweet', 'LookedupStatus']: + setattr(result, k, Status.parse(api, v)) + elif k == 'results': + setattr(result, k, Relation.parse_list(api, v)) + else: + setattr(result, k, v) + return result + + +class Relationship(Model): + @classmethod + def parse(cls, api, json): + result = cls(api) + for k, v in json.items(): + if k == 'connections': + setattr(result, 'is_following', 'following' in v) + setattr(result, 'is_followed_by', 'followed_by' in v) + else: + setattr(result, k, v) + return result + + +class JSONModel(Model): + + @classmethod + def parse(cls, api, json): + return json + + +class IDModel(Model): + + @classmethod + def parse(cls, api, json): + if isinstance(json, list): + return json + else: + return json['ids'] + + +class BoundingBox(Model): + + @classmethod + def parse(cls, api, json): + result = cls(api) + if json is not None: + for k, v in json.items(): + setattr(result, k, v) + return result + + def origin(self): + """ + Return longitude, latitude of southwest (bottom, left) corner of + bounding box, as a tuple. + + This assumes that bounding box is always a rectangle, which + appears to be the case at present. + """ + return tuple(self.coordinates[0][0]) + + def corner(self): + """ + Return longitude, latitude of northeast (top, right) corner of + bounding box, as a tuple. + + This assumes that bounding box is always a rectangle, which + appears to be the case at present. + """ + return tuple(self.coordinates[0][2]) + + +class Place(Model): + + @classmethod + def parse(cls, api, json): + place = cls(api) + for k, v in json.items(): + if k == 'bounding_box': + # bounding_box value may be null (None.) + # Example: "United States" (id=96683cc9126741d1) + if v is not None: + t = BoundingBox.parse(api, v) + else: + t = v + setattr(place, k, t) + elif k == 'contained_within': + # contained_within is a list of Places. + setattr(place, k, Place.parse_list(api, v)) + else: + setattr(place, k, v) + return place + + @classmethod + def parse_list(cls, api, json_list): + if isinstance(json_list, list): + item_list = json_list + else: + item_list = json_list['result']['places'] + + results = ResultSet() + for obj in item_list: + results.append(cls.parse(api, obj)) + return results + + +class Media(Model): + + @classmethod + def parse(cls, api, json): + media = cls(api) + for k, v in json.items(): + setattr(media, k, v) + return media + + +class ModelFactory(object): + """ + Used by parsers for creating instances + of models. You may subclass this factory + to add your own extended models. + """ + + status = Status + user = User + direct_message = DirectMessage + friendship = Friendship + saved_search = SavedSearch + search_results = SearchResults + category = Category + list = List + relation = Relation + relationship = Relationship + media = Media + + json = JSONModel + ids = IDModel + place = Place + bounding_box = BoundingBox diff --git a/apprise/plugins/NotifyTwitter/tweepy/parsers.py b/apprise/plugins/NotifyTwitter/tweepy/parsers.py new file mode 100644 index 00000000..a2ee4e87 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/parsers.py @@ -0,0 +1,109 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +from .models import ModelFactory +from .utils import import_simplejson +from .error import TweepError + + +class Parser(object): + + def parse(self, method, payload): + """ + Parse the response payload and return the result. + Returns a tuple that contains the result data and the cursors + (or None if not present). + """ + raise NotImplementedError + + def parse_error(self, payload): + """ + Parse the error message and api error code from payload. + Return them as an (error_msg, error_code) tuple. If unable to parse the + message, throw an exception and default error message will be used. + """ + raise NotImplementedError + + +class RawParser(Parser): + + def __init__(self): + pass + + def parse(self, method, payload): + return payload + + def parse_error(self, payload): + return payload + + +class JSONParser(Parser): + + payload_format = 'json' + + def __init__(self): + self.json_lib = import_simplejson() + + def parse(self, method, payload): + try: + json = self.json_lib.loads(payload) + except Exception as e: + raise TweepError('Failed to parse JSON payload: %s' % e) + + needs_cursors = 'cursor' in method.session.params + if needs_cursors and isinstance(json, dict): + if 'previous_cursor' in json: + if 'next_cursor' in json: + cursors = json['previous_cursor'], json['next_cursor'] + return json, cursors + else: + return json + + def parse_error(self, payload): + error_object = self.json_lib.loads(payload) + + if 'error' in error_object: + reason = error_object['error'] + api_code = error_object.get('code') + else: + reason = error_object['errors'] + api_code = [error.get('code') for error in + reason if error.get('code')] + api_code = api_code[0] if len(api_code) == 1 else api_code + + return reason, api_code + + +class ModelParser(JSONParser): + + def __init__(self, model_factory=None): + JSONParser.__init__(self) + self.model_factory = model_factory or ModelFactory + + def parse(self, method, payload): + try: + if method.payload_type is None: + return + model = getattr(self.model_factory, method.payload_type) + except AttributeError: + raise TweepError('No model for this payload type: ' + '%s' % method.payload_type) + + json = JSONParser.parse(self, method, payload) + if isinstance(json, tuple): + json, cursors = json + else: + cursors = None + + if method.payload_list: + result = model.parse_list(method.api, json) + else: + result = model.parse(method.api, json) + + if cursors: + return result, cursors + else: + return result diff --git a/apprise/plugins/NotifyTwitter/tweepy/streaming.py b/apprise/plugins/NotifyTwitter/tweepy/streaming.py new file mode 100644 index 00000000..dee9779e --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/streaming.py @@ -0,0 +1,466 @@ +# Tweepy +# Copyright 2009-2010 Joshua Roesslein +# See LICENSE for details. + +# Appengine users: https://developers.google.com/appengine/docs/python/sockets/#making_httplib_use_sockets + +from __future__ import absolute_import, print_function + +import logging +import re +import requests +from requests.exceptions import Timeout +from threading import Thread +from time import sleep + +import six + +import ssl + +from .models import Status +from .api import API +from .error import TweepError + +from .utils import import_simplejson +json = import_simplejson() + +STREAM_VERSION = '1.1' + + +class StreamListener(object): + + def __init__(self, api=None): + self.api = api or API() + + def on_connect(self): + """Called once connected to streaming server. + + This will be invoked once a successful response + is received from the server. Allows the listener + to perform some work prior to entering the read loop. + """ + pass + + def on_data(self, raw_data): + """Called when raw data is received from connection. + + Override this method if you wish to manually handle + the stream data. Return False to stop stream and close connection. + """ + data = json.loads(raw_data) + + if 'in_reply_to_status_id' in data: + status = Status.parse(self.api, data) + if self.on_status(status) is False: + return False + elif 'delete' in data: + delete = data['delete']['status'] + if self.on_delete(delete['id'], delete['user_id']) is False: + return False + elif 'event' in data: + status = Status.parse(self.api, data) + if self.on_event(status) is False: + return False + elif 'direct_message' in data: + status = Status.parse(self.api, data) + if self.on_direct_message(status) is False: + return False + elif 'friends' in data: + if self.on_friends(data['friends']) is False: + return False + elif 'limit' in data: + if self.on_limit(data['limit']['track']) is False: + return False + elif 'disconnect' in data: + if self.on_disconnect(data['disconnect']) is False: + return False + elif 'warning' in data: + if self.on_warning(data['warning']) is False: + return False + else: + logging.error("Unknown message type: " + str(raw_data)) + + def keep_alive(self): + """Called when a keep-alive arrived""" + return + + def on_status(self, status): + """Called when a new status arrives""" + return + + def on_exception(self, exception): + """Called when an unhandled exception occurs.""" + return + + def on_delete(self, status_id, user_id): + """Called when a delete notice arrives for a status""" + return + + def on_event(self, status): + """Called when a new event arrives""" + return + + def on_direct_message(self, status): + """Called when a new direct message arrives""" + return + + def on_friends(self, friends): + """Called when a friends list arrives. + + friends is a list that contains user_id + """ + return + + def on_limit(self, track): + """Called when a limitation notice arrives""" + return + + def on_error(self, status_code): + """Called when a non-200 status code is returned""" + return False + + def on_timeout(self): + """Called when stream connection times out""" + return + + def on_disconnect(self, notice): + """Called when twitter sends a disconnect notice + + Disconnect codes are listed here: + https://dev.twitter.com/docs/streaming-apis/messages#Disconnect_messages_disconnect + """ + return + + def on_warning(self, notice): + """Called when a disconnection warning message arrives""" + return + + +class ReadBuffer(object): + """Buffer data from the response in a smarter way than httplib/requests can. + + Tweets are roughly in the 2-12kb range, averaging around 3kb. + Requests/urllib3/httplib/socket all use socket.read, which blocks + until enough data is returned. On some systems (eg google appengine), socket + reads are quite slow. To combat this latency we can read big chunks, + but the blocking part means we won't get results until enough tweets + have arrived. That may not be a big deal for high throughput systems. + For low throughput systems we don't want to sacrafice latency, so we + use small chunks so it can read the length and the tweet in 2 read calls. + """ + + def __init__(self, stream, chunk_size, encoding='utf-8'): + self._stream = stream + self._buffer = six.b('') + self._chunk_size = chunk_size + self._encoding = encoding + + def read_len(self, length): + while not self._stream.closed: + if len(self._buffer) >= length: + return self._pop(length) + read_len = max(self._chunk_size, length - len(self._buffer)) + self._buffer += self._stream.read(read_len) + + def read_line(self, sep=six.b('\n')): + """Read the data stream until a given separator is found (default \n) + + :param sep: Separator to read until. Must by of the bytes type (str in python 2, + bytes in python 3) + :return: The str of the data read until sep + """ + start = 0 + while not self._stream.closed: + loc = self._buffer.find(sep, start) + if loc >= 0: + return self._pop(loc + len(sep)) + else: + start = len(self._buffer) + self._buffer += self._stream.read(self._chunk_size) + + def _pop(self, length): + r = self._buffer[:length] + self._buffer = self._buffer[length:] + return r.decode(self._encoding) + + +class Stream(object): + + host = 'stream.twitter.com' + + def __init__(self, auth, listener, **options): + self.auth = auth + self.listener = listener + self.running = False + self.timeout = options.get("timeout", 300.0) + self.retry_count = options.get("retry_count") + # values according to + # https://dev.twitter.com/docs/streaming-apis/connecting#Reconnecting + self.retry_time_start = options.get("retry_time", 5.0) + self.retry_420_start = options.get("retry_420", 60.0) + self.retry_time_cap = options.get("retry_time_cap", 320.0) + self.snooze_time_step = options.get("snooze_time", 0.25) + self.snooze_time_cap = options.get("snooze_time_cap", 16) + + # The default socket.read size. Default to less than half the size of + # a tweet so that it reads tweets with the minimal latency of 2 reads + # per tweet. Values higher than ~1kb will increase latency by waiting + # for more data to arrive but may also increase throughput by doing + # fewer socket read calls. + self.chunk_size = options.get("chunk_size", 512) + + self.verify = options.get("verify", True) + + self.api = API() + self.headers = options.get("headers") or {} + self.new_session() + self.body = None + self.retry_time = self.retry_time_start + self.snooze_time = self.snooze_time_step + + def new_session(self): + self.session = requests.Session() + self.session.headers = self.headers + self.session.params = None + + def _run(self): + # Authenticate + url = "https://%s%s" % (self.host, self.url) + + # Connect and process the stream + error_counter = 0 + resp = None + exception = None + while self.running: + if self.retry_count is not None: + if error_counter > self.retry_count: + # quit if error count greater than retry count + break + try: + auth = self.auth.apply_auth() + resp = self.session.request('POST', + url, + data=self.body, + timeout=self.timeout, + stream=True, + auth=auth, + verify=self.verify) + if resp.status_code != 200: + if self.listener.on_error(resp.status_code) is False: + break + error_counter += 1 + if resp.status_code == 420: + self.retry_time = max(self.retry_420_start, + self.retry_time) + sleep(self.retry_time) + self.retry_time = min(self.retry_time * 2, + self.retry_time_cap) + else: + error_counter = 0 + self.retry_time = self.retry_time_start + self.snooze_time = self.snooze_time_step + self.listener.on_connect() + self._read_loop(resp) + except (Timeout, ssl.SSLError) as exc: + # This is still necessary, as a SSLError can actually be + # thrown when using Requests + # If it's not time out treat it like any other exception + if isinstance(exc, ssl.SSLError): + if not (exc.args and 'timed out' in str(exc.args[0])): + exception = exc + break + if self.listener.on_timeout() is False: + break + if self.running is False: + break + sleep(self.snooze_time) + self.snooze_time = min(self.snooze_time + self.snooze_time_step, + self.snooze_time_cap) + except Exception as exc: + exception = exc + # any other exception is fatal, so kill loop + break + + # cleanup + self.running = False + if resp: + resp.close() + + self.new_session() + + if exception: + # call a handler first so that the exception can be logged. + self.listener.on_exception(exception) + raise exception + + def _data(self, data): + if self.listener.on_data(data) is False: + self.running = False + + def _read_loop(self, resp): + charset = resp.headers.get('content-type', default='') + enc_search = re.search('charset=(?P\S*)', charset) + if enc_search is not None: + encoding = enc_search.group('enc') + else: + encoding = 'utf-8' + + buf = ReadBuffer(resp.raw, self.chunk_size, encoding=encoding) + + while self.running and not resp.raw.closed: + length = 0 + while not resp.raw.closed: + line = buf.read_line().strip() + if not line: + self.listener.keep_alive() # keep-alive new lines are expected + elif line.isdigit(): + length = int(line) + break + else: + raise TweepError('Expecting length, unexpected value found') + + next_status_obj = buf.read_len(length) + if self.running: + self._data(next_status_obj) + + # # Note: keep-alive newlines might be inserted before each length value. + # # read until we get a digit... + # c = b'\n' + # for c in resp.iter_content(decode_unicode=True): + # if c == b'\n': + # continue + # break + # + # delimited_string = c + # + # # read rest of delimiter length.. + # d = b'' + # for d in resp.iter_content(decode_unicode=True): + # if d != b'\n': + # delimited_string += d + # continue + # break + # + # # read the next twitter status object + # if delimited_string.decode('utf-8').strip().isdigit(): + # status_id = int(delimited_string) + # next_status_obj = resp.raw.read(status_id) + # if self.running: + # self._data(next_status_obj.decode('utf-8')) + + + if resp.raw.closed: + self.on_closed(resp) + + def _start(self, async): + self.running = True + if async: + self._thread = Thread(target=self._run) + self._thread.start() + else: + self._run() + + def on_closed(self, resp): + """ Called when the response has been closed by Twitter """ + pass + + def userstream(self, + stall_warnings=False, + _with=None, + replies=None, + track=None, + locations=None, + async=False, + encoding='utf8'): + self.session.params = {'delimited': 'length'} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/user.json' % STREAM_VERSION + self.host = 'userstream.twitter.com' + if stall_warnings: + self.session.params['stall_warnings'] = stall_warnings + if _with: + self.session.params['with'] = _with + if replies: + self.session.params['replies'] = replies + if locations and len(locations) > 0: + if len(locations) % 4 != 0: + raise TweepError("Wrong number of locations points, " + "it has to be a multiple of 4") + self.session.params['locations'] = ','.join(['%.2f' % l for l in locations]) + if track: + self.session.params['track'] = u','.join(track).encode(encoding) + + self._start(async) + + def firehose(self, count=None, async=False): + self.session.params = {'delimited': 'length'} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/statuses/firehose.json' % STREAM_VERSION + if count: + self.url += '&count=%s' % count + self._start(async) + + def retweet(self, async=False): + self.session.params = {'delimited': 'length'} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/statuses/retweet.json' % STREAM_VERSION + self._start(async) + + def sample(self, async=False, languages=None): + self.session.params = {'delimited': 'length'} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/statuses/sample.json' % STREAM_VERSION + if languages: + self.session.params['language'] = ','.join(map(str, languages)) + self._start(async) + + def filter(self, follow=None, track=None, async=False, locations=None, + stall_warnings=False, languages=None, encoding='utf8', filter_level=None): + self.body = {} + self.session.headers['Content-type'] = "application/x-www-form-urlencoded" + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/statuses/filter.json' % STREAM_VERSION + if follow: + self.body['follow'] = u','.join(follow).encode(encoding) + if track: + self.body['track'] = u','.join(track).encode(encoding) + if locations and len(locations) > 0: + if len(locations) % 4 != 0: + raise TweepError("Wrong number of locations points, " + "it has to be a multiple of 4") + self.body['locations'] = u','.join(['%.4f' % l for l in locations]) + if stall_warnings: + self.body['stall_warnings'] = stall_warnings + if languages: + self.body['language'] = u','.join(map(str, languages)) + if filter_level: + self.body['filter_level'] = unicode(filter_level, encoding) + self.session.params = {'delimited': 'length'} + self.host = 'stream.twitter.com' + self._start(async) + + def sitestream(self, follow, stall_warnings=False, + with_='user', replies=False, async=False): + self.body = {} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/site.json' % STREAM_VERSION + self.body['follow'] = u','.join(map(six.text_type, follow)) + self.body['delimited'] = 'length' + if stall_warnings: + self.body['stall_warnings'] = stall_warnings + if with_: + self.body['with'] = with_ + if replies: + self.body['replies'] = replies + self._start(async) + + def disconnect(self): + if self.running is False: + return + self.running = False diff --git a/apprise/plugins/NotifyTwitter/tweepy/utils.py b/apprise/plugins/NotifyTwitter/tweepy/utils.py new file mode 100644 index 00000000..36d34025 --- /dev/null +++ b/apprise/plugins/NotifyTwitter/tweepy/utils.py @@ -0,0 +1,58 @@ +# Tweepy +# Copyright 2010 Joshua Roesslein +# See LICENSE for details. + +from __future__ import print_function + +from datetime import datetime + +import six +from six.moves.urllib.parse import quote + +from email.utils import parsedate + + +def parse_datetime(string): + return datetime(*(parsedate(string)[:6])) + + +def parse_html_value(html): + + return html[html.find('>')+1:html.rfind('<')] + + +def parse_a_href(atag): + + start = atag.find('"') + 1 + end = atag.find('"', start) + return atag[start:end] + + +def convert_to_utf8_str(arg): + # written by Michael Norton (http://docondev.blogspot.com/) + if isinstance(arg, six.text_type): + arg = arg.encode('utf-8') + elif not isinstance(arg, bytes): + arg = six.text_type(arg).encode('utf-8') + return arg + + +def import_simplejson(): + try: + import simplejson as json + except ImportError: + try: + import json # Python 2.6+ + except ImportError: + try: + # Google App Engine + from django.utils import simplejson as json + except ImportError: + raise ImportError("Can't load a json library") + + return json + + +def list_to_csv(item_list): + if item_list: + return ','.join([str(i) for i in item_list]) diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py new file mode 100644 index 00000000..7891bc74 --- /dev/null +++ b/apprise/plugins/NotifyXBMC.py @@ -0,0 +1,221 @@ +# -*- encoding: utf-8 -*- +# +# XBMC Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from json import dumps +import requests + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyType +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Image Support (128x128) +XBMC_IMAGE_XY = NotifyImageSize.XY_128 + +# XBMC uses the http protocol with JSON requests +XBMC_PORT = 8080 + +XBMC_PROTOCOL_V2 = 2 +XBMC_PROTOCOL_V6 = 6 + +SUPPORTED_XBMC_PROTOCOLS = ( + XBMC_PROTOCOL_V2, + XBMC_PROTOCOL_V6, +) + + +class NotifyXBMC(NotifyBase): + """ + A wrapper for XBMC/KODI Notifications + """ + + # The default protocol + PROTOCOL = ('xbmc', 'kodi') + + # The default secure protocol + SECURE_PROTOCOL = ('xbmc', 'kodis') + + def __init__(self, **kwargs): + """ + Initialize XBMC/KODI Object + """ + super(NotifyXBMC, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=XBMC_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + if self.secure: + self.schema = 'https' + else: + self.schema = 'http' + + if not self.port: + self.port = XBMC_PORT + + self.protocol = kwargs.get('protocol', XBMC_PROTOCOL_V2) + if self.protocol not in SUPPORTED_XBMC_PROTOCOLS: + raise TypeError("Invalid protocol specified.") + + return + + def _payload_60(self, title, body, notify_type, **kwargs): + """ + Builds payload for KODI API v6.0 + + Returns (headers, payload) + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare JSON Object + payload = { + 'jsonrpc': '6.0', + 'method': 'GUI.ShowNotification', + 'params': { + 'title': title, + 'message': body, + # displaytime is defined in microseconds + 'displaytime': 12000, + }, + 'id': 1, + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + payload['image'] = image_url + if notify_type is NotifyType.Error: + payload['type'] = 'error' + elif notify_type is NotifyType.Warning: + payload['type'] = 'warning' + else: + payload['type'] = 'info' + + return (headers, dumps(payload)) + + def _payload_20(self, title, body, notify_type, **kwargs): + """ + Builds payload for XBMC API v2.0 + + Returns (headers, payload) + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare JSON Object + payload = { + 'jsonrpc': '2.0', + 'method': 'GUI.ShowNotification', + 'params': { + 'title': title, + 'message': body, + # displaytime is defined in microseconds + 'displaytime': 12000, + }, + 'id': 1, + } + + if self.include_image: + image_url = self.image_url( + notify_type, + ) + if image_url: + payload['image'] = image_url + + return (headers, dumps(payload)) + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform XBMC Notification + """ + + if self.protocol == XBMC_PROTOCOL_V2: + # XBMC v2.0 + (headers, payload) = self._payload_20( + title, body, notify_type, **kwargs) + + else: + # XBMC v6.0 + (headers, payload) = self._payload_60( + title, body, notify_type, **kwargs) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += '/jsonrpc' + + self.logger.debug('XBMC/KODI POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('XBMC/KODI Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send XBMC/KODI notification:' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send XBMC/KODI notification ' + '(error=%s).' % r.status_code) + + # Return; we're done + return False + else: + self.logger.info('Sent XBMC/KODI notification.') + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending XBMC/KODI ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py new file mode 100644 index 00000000..3f25dfb6 --- /dev/null +++ b/apprise/plugins/NotifyXML.py @@ -0,0 +1,154 @@ +# -*- encoding: utf-8 -*- +# +# XML Notify Wrapper +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from urllib import quote +import requests +import re + +from .NotifyBase import NotifyBase +from .NotifyBase import NotifyFormat +from .NotifyBase import NotifyImageSize +from .NotifyBase import HTTP_ERROR_MAP + +# Image Support (128x128) +XML_IMAGE_XY = NotifyImageSize.XY_128 + + +class NotifyXML(NotifyBase): + """ + A wrapper for XML Notifications + """ + + # The default protocol + PROTOCOL = 'xml' + + # The default secure protocol + SECURE_PROTOCOL = 'xmls' + + def __init__(self, **kwargs): + """ + Initialize XML Object + """ + super(NotifyXML, self).__init__( + title_maxlen=250, body_maxlen=32768, + image_size=XML_IMAGE_XY, + notify_format=NotifyFormat.TEXT, + **kwargs) + + self.payload = """ + + + + 1.0 + {SUBJECT} + {MESSAGE_TYPE} + {MESSAGE} + + +""" + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, basestring): + self.fullpath = '/' + + return + + def _notify(self, title, body, notify_type, **kwargs): + """ + Perform XML Notification + """ + + # prepare XML Object + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/xml' + } + + re_map = { + '{MESSAGE_TYPE}': quote(notify_type), + '{SUBJECT}': quote(title), + '{MESSAGE}': quote(body), + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + payload = re_table.sub(lambda x: re_map[x.group()], self.payload) + + self.logger.debug('XML POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('XML Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + try: + self.logger.warning( + 'Failed to send XML notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send XML notification ' + '(error=%s).' % r.status_code) + + # Return; we're done + return False + + except requests.ConnectionError as e: + self.logger.warning( + 'A Connection error occured sending XML ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py new file mode 100644 index 00000000..0936724f --- /dev/null +++ b/apprise/plugins/__init__.py @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +# +# Our service wrappers +# +# Copyright (C) 2014-2017 Chris Caron +# +# This file is part of apprise. +# +# apprise is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# apprise 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with apprise. If not, see . + +from .NotifyBoxcar import NotifyBoxcar +from .NotifyEmail import NotifyEmail +from .NotifyFaast import NotifyFaast +from .NotifyGrowl import NotifyGrowl +from .NotifyJSON import NotifyJSON +from .NotifyMyAndroid import NotifyMyAndroid +from .NotifyProwl import NotifyProwl +from .NotifyPushalot import NotifyPushalot +from .NotifyPushBullet import NotifyPushBullet +from .NotifyPushover import NotifyPushover +from .NotifyRocketChat import NotifyRocketChat +from .NotifyToasty import NotifyToasty +from .NotifyTwitter import NotifyTwitter +from .NotifyXBMC import NotifyXBMC +from .NotifyXML import NotifyXML +from .NotifySlack import NotifySlack +from .NotifyJoin import NotifyJoin +from .NotifyTelegram import NotifyTelegram +from .NotifyMatterMost import NotifyMatterMost +from .NotifyPushjet import NotifyPushjet + +__all__ = [ + # Notification Services + 'NotifyBoxcar', 'NotifyEmail', 'NotifyFaast', 'NotifyGrowl', 'NotifyJSON', + 'NotifyMyAndroid', 'NotifyProwl', 'NotifyPushalot', 'NotifyPushBullet', + 'NotifyPushover', 'NotifyRocketChat', 'NotifyToasty', 'NotifyTwitter', + 'NotifyXBMC', 'NotifyXML', 'NotifySlack', 'NotifyJoin', 'NotifyTelegram', + 'NotifyMatterMost', 'NotifyPushjet' +] diff --git a/apprise/var/NotifyXML-1.0.xsd b/apprise/var/NotifyXML-1.0.xsd new file mode 100644 index 00000000..d9b7235a --- /dev/null +++ b/apprise/var/NotifyXML-1.0.xsd @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apprise/var/apprise-failure-128x128.png b/apprise/var/apprise-failure-128x128.png new file mode 100644 index 00000000..764271f6 Binary files /dev/null and b/apprise/var/apprise-failure-128x128.png differ diff --git a/apprise/var/apprise-failure-256x256.png b/apprise/var/apprise-failure-256x256.png new file mode 100644 index 00000000..8ea6dc62 Binary files /dev/null and b/apprise/var/apprise-failure-256x256.png differ diff --git a/apprise/var/apprise-failure-72x72.png b/apprise/var/apprise-failure-72x72.png new file mode 100644 index 00000000..1cb418ba Binary files /dev/null and b/apprise/var/apprise-failure-72x72.png differ diff --git a/apprise/var/apprise-info-128x128.png b/apprise/var/apprise-info-128x128.png new file mode 100644 index 00000000..b280c6c1 Binary files /dev/null and b/apprise/var/apprise-info-128x128.png differ diff --git a/apprise/var/apprise-info-256x256.png b/apprise/var/apprise-info-256x256.png new file mode 100644 index 00000000..44df0c7c Binary files /dev/null and b/apprise/var/apprise-info-256x256.png differ diff --git a/apprise/var/apprise-info-72x72.png b/apprise/var/apprise-info-72x72.png new file mode 100644 index 00000000..2f4b8f34 Binary files /dev/null and b/apprise/var/apprise-info-72x72.png differ diff --git a/apprise/var/apprise-success-128x128.png b/apprise/var/apprise-success-128x128.png new file mode 100644 index 00000000..c4a52530 Binary files /dev/null and b/apprise/var/apprise-success-128x128.png differ diff --git a/apprise/var/apprise-success-256x256.png b/apprise/var/apprise-success-256x256.png new file mode 100644 index 00000000..3a448d75 Binary files /dev/null and b/apprise/var/apprise-success-256x256.png differ diff --git a/apprise/var/apprise-success-72x72.png b/apprise/var/apprise-success-72x72.png new file mode 100644 index 00000000..a0eb3297 Binary files /dev/null and b/apprise/var/apprise-success-72x72.png differ diff --git a/apprise/var/apprise-warning-128x128.png b/apprise/var/apprise-warning-128x128.png new file mode 100644 index 00000000..a731c721 Binary files /dev/null and b/apprise/var/apprise-warning-128x128.png differ diff --git a/apprise/var/apprise-warning-256x256.png b/apprise/var/apprise-warning-256x256.png new file mode 100644 index 00000000..f9a771d9 Binary files /dev/null and b/apprise/var/apprise-warning-256x256.png differ diff --git a/apprise/var/apprise-warning-72x72.png b/apprise/var/apprise-warning-72x72.png new file mode 100644 index 00000000..d901d731 Binary files /dev/null and b/apprise/var/apprise-warning-72x72.png differ diff --git a/bin/apprise.py b/bin/apprise.py new file mode 100755 index 00000000..6fc04fa2 --- /dev/null +++ b/bin/apprise.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + + +def _main(): + """\ + Usage: apprise [options] [URL ...] + + Send notifications to a variety of different supported services. + See also https://github.com/caronc/apprise + + URL The notification service URL + + Options: + + -h, --help show this message + -t TITLE, --title TITLE Specify a notification title. + -b BODY, --body BODY Specify a notification body. + -i IMGURL, --image IMGURL Specify an image to send with the notification. + The image should be in the format of a URL + string such as file:///local/path/to/file.png or + a remote site like: http://my.host/my.image.png. + """ + + +if __name__ == '__main__': + _main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..55a90672 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +chardet +markdown +decorator +requests +requests-oauthlib +oauthlib +urllib3 +six +click diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..04b1ff2d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..0dc02abc --- /dev/null +++ b/setup.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# SetupTools Script +# +# Copyright (C) 2017 Chris Caron +# +# 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. +# + +import os +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +from setuptools import find_packages + +install_options = os.environ.get("APPRISE_INSTALL", "").split(",") +libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"]) +if libonly_flags.intersection(install_options): + console_scripts = [] +else: + console_scripts = ['apprise = apprise:_main'] + +setup( + name='apprise', + version='0.0.1', + description='A friendly notification hub', + license='GPLv3', + long_description=open('README.md').read(), + url='https://github.com/caronc/apprise', + keywords='push notifications email boxcar faast growl Join KODI ' + 'Mattermost NotifyMyAndroid Prowl Pushalot PushBullet Pushjet ' + 'Pushover Rocket.Chat Slack Toasty Telegram Twitter XBMC ', + author='Chris Caron', + author_email='lead2gold@gmail.com', + packages=find_packages(), + package_data={ + 'apprise': ['var/*'], + }, + include_package_data=True, + scripts=['bin/apprise.py', ], + install_requires=open('requirements.txt').readlines(), + classifiers=( + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + 'Natural Language :: English', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + ), + entry_points={'console_scripts': console_scripts}, + python_requires='>=2.7, <3', +) diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 00000000..f694d21c --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,12 @@ +"""API properties. + +""" + +from __future__ import print_function +from __future__ import unicode_literals +from apprise import Apprise + + +def test_initialization(): + "API: apprise() test initialization""" + a = Apprise()