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()