From a8f57a8b6e3576fef632decd6f40060f94ed24f9 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 25 Nov 2017 16:51:46 -0500 Subject: [PATCH] initial commit --- .gitignore | 63 + .travis.yml | 15 + MANIFEST.in | 6 + README.md | 49 + apprise/Apprise.py | 175 +++ apprise/Utils.py | 350 +++++ apprise/__init__.py | 30 + apprise/plugins/NotifyBase.py | 445 ++++++ apprise/plugins/NotifyBoxcar.py | 178 +++ apprise/plugins/NotifyEmail.py | 317 ++++ apprise/plugins/NotifyFaast.py | 123 ++ apprise/plugins/NotifyGrowl/NotifyGrowl.py | 193 +++ apprise/plugins/NotifyGrowl/__init__.py | 6 + apprise/plugins/NotifyGrowl/gntp/__init__.py | 0 apprise/plugins/NotifyGrowl/gntp/cli.py | 141 ++ apprise/plugins/NotifyGrowl/gntp/config.py | 77 + apprise/plugins/NotifyGrowl/gntp/core.py | 511 +++++++ apprise/plugins/NotifyGrowl/gntp/errors.py | 25 + apprise/plugins/NotifyGrowl/gntp/notifier.py | 265 ++++ apprise/plugins/NotifyGrowl/gntp/shim.py | 45 + apprise/plugins/NotifyGrowl/gntp/version.py | 4 + apprise/plugins/NotifyJSON.py | 136 ++ apprise/plugins/NotifyJoin.py | 212 +++ apprise/plugins/NotifyMatterMost.py | 172 +++ apprise/plugins/NotifyMyAndroid.py | 173 +++ apprise/plugins/NotifyProwl.py | 177 +++ apprise/plugins/NotifyPushBullet.py | 167 ++ apprise/plugins/NotifyPushalot.py | 146 ++ apprise/plugins/NotifyPushjet.py | 76 + .../plugins/NotifyPushjet/NotifyPushjet.py | 76 + apprise/plugins/NotifyPushjet/__init__.py | 6 + .../plugins/NotifyPushjet/pushjet/__init__.py | 6 + .../plugins/NotifyPushjet/pushjet/errors.py | 49 + .../plugins/NotifyPushjet/pushjet/pushjet.py | 319 ++++ .../NotifyPushjet/pushjet/utilities.py | 66 + apprise/plugins/NotifyPushover.py | 222 +++ apprise/plugins/NotifyRocketChat.py | 307 ++++ apprise/plugins/NotifySlack.py | 287 ++++ apprise/plugins/NotifyTelegram.py | 412 +++++ apprise/plugins/NotifyToasty.py | 155 ++ .../plugins/NotifyTwitter/NotifyTwitter.py | 116 ++ apprise/plugins/NotifyTwitter/__init__.py | 6 + .../plugins/NotifyTwitter/tweepy/__init__.py | 25 + apprise/plugins/NotifyTwitter/tweepy/api.py | 1348 +++++++++++++++++ apprise/plugins/NotifyTwitter/tweepy/auth.py | 178 +++ .../plugins/NotifyTwitter/tweepy/binder.py | 256 ++++ apprise/plugins/NotifyTwitter/tweepy/cache.py | 435 ++++++ .../plugins/NotifyTwitter/tweepy/cursor.py | 214 +++ apprise/plugins/NotifyTwitter/tweepy/error.py | 34 + .../plugins/NotifyTwitter/tweepy/models.py | 493 ++++++ .../plugins/NotifyTwitter/tweepy/parsers.py | 109 ++ .../plugins/NotifyTwitter/tweepy/streaming.py | 466 ++++++ apprise/plugins/NotifyTwitter/tweepy/utils.py | 58 + apprise/plugins/NotifyXBMC.py | 221 +++ apprise/plugins/NotifyXML.py | 154 ++ apprise/plugins/__init__.py | 50 + apprise/var/NotifyXML-1.0.xsd | 22 + apprise/var/apprise-failure-128x128.png | Bin 0 -> 16152 bytes apprise/var/apprise-failure-256x256.png | Bin 0 -> 41650 bytes apprise/var/apprise-failure-72x72.png | Bin 0 -> 7694 bytes apprise/var/apprise-info-128x128.png | Bin 0 -> 16648 bytes apprise/var/apprise-info-256x256.png | Bin 0 -> 42935 bytes apprise/var/apprise-info-72x72.png | Bin 0 -> 7933 bytes apprise/var/apprise-success-128x128.png | Bin 0 -> 17391 bytes apprise/var/apprise-success-256x256.png | Bin 0 -> 48265 bytes apprise/var/apprise-success-72x72.png | Bin 0 -> 7948 bytes apprise/var/apprise-warning-128x128.png | Bin 0 -> 16550 bytes apprise/var/apprise-warning-256x256.png | Bin 0 -> 42971 bytes apprise/var/apprise-warning-72x72.png | Bin 0 -> 7847 bytes bin/apprise.py | 26 + requirements.txt | 9 + setup.cfg | 4 + setup.py | 64 + test/test_api.py | 12 + 74 files changed, 10482 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 apprise/Apprise.py create mode 100644 apprise/Utils.py create mode 100644 apprise/__init__.py create mode 100644 apprise/plugins/NotifyBase.py create mode 100644 apprise/plugins/NotifyBoxcar.py create mode 100644 apprise/plugins/NotifyEmail.py create mode 100644 apprise/plugins/NotifyFaast.py create mode 100644 apprise/plugins/NotifyGrowl/NotifyGrowl.py create mode 100644 apprise/plugins/NotifyGrowl/__init__.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/__init__.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/cli.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/config.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/core.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/errors.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/notifier.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/shim.py create mode 100644 apprise/plugins/NotifyGrowl/gntp/version.py create mode 100644 apprise/plugins/NotifyJSON.py create mode 100644 apprise/plugins/NotifyJoin.py create mode 100644 apprise/plugins/NotifyMatterMost.py create mode 100644 apprise/plugins/NotifyMyAndroid.py create mode 100644 apprise/plugins/NotifyProwl.py create mode 100644 apprise/plugins/NotifyPushBullet.py create mode 100644 apprise/plugins/NotifyPushalot.py create mode 100644 apprise/plugins/NotifyPushjet.py create mode 100644 apprise/plugins/NotifyPushjet/NotifyPushjet.py create mode 100644 apprise/plugins/NotifyPushjet/__init__.py create mode 100644 apprise/plugins/NotifyPushjet/pushjet/__init__.py create mode 100644 apprise/plugins/NotifyPushjet/pushjet/errors.py create mode 100644 apprise/plugins/NotifyPushjet/pushjet/pushjet.py create mode 100644 apprise/plugins/NotifyPushjet/pushjet/utilities.py create mode 100644 apprise/plugins/NotifyPushover.py create mode 100644 apprise/plugins/NotifyRocketChat.py create mode 100644 apprise/plugins/NotifySlack.py create mode 100644 apprise/plugins/NotifyTelegram.py create mode 100644 apprise/plugins/NotifyToasty.py create mode 100644 apprise/plugins/NotifyTwitter/NotifyTwitter.py create mode 100644 apprise/plugins/NotifyTwitter/__init__.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/__init__.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/api.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/auth.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/binder.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/cache.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/cursor.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/error.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/models.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/parsers.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/streaming.py create mode 100644 apprise/plugins/NotifyTwitter/tweepy/utils.py create mode 100644 apprise/plugins/NotifyXBMC.py create mode 100644 apprise/plugins/NotifyXML.py create mode 100644 apprise/plugins/__init__.py create mode 100644 apprise/var/NotifyXML-1.0.xsd create mode 100644 apprise/var/apprise-failure-128x128.png create mode 100644 apprise/var/apprise-failure-256x256.png create mode 100644 apprise/var/apprise-failure-72x72.png create mode 100644 apprise/var/apprise-info-128x128.png create mode 100644 apprise/var/apprise-info-256x256.png create mode 100644 apprise/var/apprise-info-72x72.png create mode 100644 apprise/var/apprise-success-128x128.png create mode 100644 apprise/var/apprise-success-256x256.png create mode 100644 apprise/var/apprise-success-72x72.png create mode 100644 apprise/var/apprise-warning-128x128.png create mode 100644 apprise/var/apprise-warning-256x256.png create mode 100644 apprise/var/apprise-warning-72x72.png create mode 100755 bin/apprise.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 test/test_api.py 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 0000000000000000000000000000000000000000..764271f69e138ab58c080577d46e03ef0172c711 GIT binary patch literal 16152 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 literal 0 HcmV?d00001 diff --git a/apprise/var/apprise-failure-256x256.png b/apprise/var/apprise-failure-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea6dc62fcfb641a8dd1dad1ba8f8c39bacc3866 GIT binary patch literal 41650 zcmaf*bx<5lwDxy#hXi*gSa5d@?(Po3-CYyho#48-y9Xy|aCdiEg5G_5?Q#mOKz{h{D z{O+=3$R0!|X&qMp02%YY4-k-*g9q6O?a6k_Zaqh=k3qmDF6=OjRBTd*ZU3X(@UrzwYpdcawp=5#pG(f0+5i+VEg%OPN z2Pw_K_C;tgwl{-drVziHkrXu&zk|Koi-i#6+t zEaWmrhzD~IybbcpN1$B{jJjXC1@!hPLH&dx7Ganv{AfC@l?;Y6hIgtv!U5w2k^D0* zA>zTtgZm5k0&EUKS9TkEJlU$9F;Xk;{ng`%EiKa<+p9rVK^T?t4(2lEycVj%_d?}a zzgxJ69L(#22oQZM!(~CvK$4$JeH&z~4Cd_X4YuebCoBEKfclt=9h?x1 z7mN4P-?!TqH5Q&J?HoECx&KnL75erp$lG~zbDS8T1QYz(Kiue^Rr9{)opJ6;Co%xt zxA+STPeCz32EY%B2N|{CDzRpQ%NX$*rA`x_*r#{|;P?FiWgE&Ei3RQJfrtLk7nsx? z7RpKxo}q?ht@2Nby!c(?1hL+{5Rss|X{p@lA4aoG%!#s}6lhN5X{_T%tmUY_M-F{g zpy9=eth2r{&Y~A`Zvj(8 zyh-y?F~&h#N=Es#1d>#r^Fax+qV(Y)Q052u-I|PBQBYwJ1_F6g@56^iZ{A{;z-h2S zBn-2|`sf`Y*G!5uQLXPrux5fAyOZN|(UKWJ2;m00q)#iDloYuH43ykq*)dKqgoT|X z;Ysiok5DF#S6g027*X00*!(1HZ((tc2;=2&rfIm+o-BTG;WljAEk(s`*xj5|M*RQU%<k6O8DePA`I zt`||fiIMkk+2XLj+!w@yKHE6{I6S;$XxrG*gf<5E)_2=soTCIiIP&l9P*GD$AR&Fn z2w5T|XtP~mdz7M(y5r}I(bRKdz6R$?wTu9WP&3h9aqt40G++HBcUHaccq*GxoaB&g zD1bL@o(CSP+GR^svJTi^YWqOj!yj;Q#$88Y3~k;R!GM9Qvc|RyY6XSY`+r9#(j?6y zwOCJr7rm{pl@QHS8tnUN5$GaFl zYo=G&^#`h1fCY7ER#ue}|9OH#P{JA(6oPW&*Z4C0a!#*NnabS_8Bi^~nE zz43RzE57#1-A^zcfWP9K_`NXlJ%19k0ZV{@5ixh%sWys{xcHKp2S24#VU!x4i5H3{ zrSU)El`?eS5Yfwk7kixEhs8w0KLH&1PUM1;l7es|NsdrU0RQ-XGOg*%L0q4Qfvvd^ zr|6Skn%+08FML(xG07^B0agYJ_wKqtoYC*3gi_E|@S1&t;g}Ubs-`l-cTECU>JA1h zl$5+K$$W7(qz#)}OzWu_Kn!n;J!NqQ%2U+1m&d|{LSEj=VYzD7@yTi-e6G1_g zS3Su6`W{8<1;Ya$i_s`I*IWM6cbYG|C-w(9jyM^|TN-MR0}K|Wg;nuc+>b&%RE3(5 zyvv$V4|l*b*T^K^Xn_j71t}AN0Zl<}q%gFEt-V|VEsU}grcY)rqd}rk zw4EZ`lq;^Ht*Jdao&MF#%FN7jstNju=$e!xAcO?kDMS|0iTB#JX_sVLRYQL=y-e{S z#~8y%F1lwfTgzWkWfHCr0Xl}MgHqk6K-hnv0_j>J>L5Nm-53>_aYQjS?be)QoOIJG z)IdSOK+xxN+vnJv;`R|hju#t28+Nr+uiWiLCOdDhl93qdL+SFSIRFcG$V-U}rp+Aa z()K`e@J?$9XBQO|7bQAsf>4;6`f9Ny()Y;X+rv5BbMR?nnfSH^Hn zI^Gg=I&k_CGS0R>&91sRo{TGcM-6}#1yhwuRDY&K6>ecm_Dah7EPzjXQ9ZZd5bjL@ z_ZM;AC|8tmL%!=| z+psOd&(My~TTgmDaGhuM4vt#QUK}VH2ihmT8?h|(6VVL}9jY4N%2f4v%;4aIVjmt1 zc%OB?-w|pS#VVfN0EDR6aH?m`Otn0o-^zrBGPF@jR>cTI^(fR-jw0^#8}2#>otSHcc@=slA*ncDiSMu*xv)(8DdXK9etwAB;`x?A zX25GU)L71z$ul(0l9F}jRl8es63Ad*Pu~nYJ*iBjplkVbcEkpVaxW1k7-yo}bXK`% zI?1r%5_6HAG3Dqm$Ef-vE){~kynulqYPHpq^Rez~$Lm(kAZ06I=5II)>C-&zfa_@Sk*3e@Tx63(0hppp(F@PZHWV;00Fdr-Gp;^(E}? zHHzw811=bkg|Oi(<)60TtE2j6TN$^DHsqO%C|{PKJ--btKp@9G#S&Wg?Ol7ork~I+ zSy47%skTx4*?fHu1<)VAl{8(0w7>#7%ybwMx`(`R%v%ee4LG@_oew zRklC;)lnxEFwUFj4(e&z-Oc%!z6GSnD+H;a{v6Q-5YGG=^ZI!yaGXY}(aVK3bgrN+ zj$QP)wY<5!BVkpF-EQu{wUaJ|sRYqa>Am z;x$l$sPr3STZe;VF?kYHeQ+uoib4F#lKPw2oxPbqc?mS}oWF~QNb5H*8CYEs4`(X8 z;QAIqq0B zz{clLH5W0b7aI~xUaXo0B{%XJbc!cjG54Y!8-@LM{sT9Hf;d=3`M{><5)iS$3N_rr zRXw6~cyQAzm*#dLT?^gFEBXYlgaxgP^eQQ3;3{iEHafsQZT?gsu2b2Qr0GTIxixg5 zRctRmel-Me^BnSIAv@97WJ0CI2Wr5fe;xm2ICQY5eS0uKv#mfny}VQ?(emPk`Kor& z$5%VPz7P|f`~}dpR)(G~{EzC-Z21y{7hD^Hy|zp|kVotWTFKkJItmJSDRjmdML^JC z<_Qx;F_ItUs%Z2GxR(zqL_i;=z|^I6vD-o|(Yg?TlQ{`1t~!Muqb#p|US#Q4qznJG z7QomQa_I%_qE$3>-K4JNdbm5p;SCKRtNbwUH(Cj&1Ot7|Y#dRRx^;EL?0T5b!CjWB zS&X|q3CeqfViAM(mDCJtKhT=(XQRgbmfv|W{Fs|+#Ej*l?k7H$V#e-zU!LGEElXAW zrh&X|iHE#Xf6r}zus?vYZ;8;MZ)%4JJQ1j9U@LvwBjq^C+DS;{o`D;K0J+1st)bcb z?Ax>R;3%Jj)DN8kROo!#ju1VSfG?71C?W(atyTA$F(OkigN9!)60enPg(`Ix1}jU+;(6c{2fP_+H|kG&}c%E>a)G&0|c%4cb9 z1ak31vgA9^X?Z_Wk`d+qyK_(yfaGl_}hFU68*EbbLW5j^raE z_56hu9^i+4c+)9^%QS$g3ouO2um=T~_;FB*r#Vc@eA)5G zBN#+K;O-9d{3rI!XAu92tQQRqmxc$?VRp(41D=ZH1981Og#&HVUi5#aKtb$ zcbTi7b-Dmz<|%7V)ej%thiBnybxNJN!9^E9H;eD(3fe$r7I*rBoxw)AVhe6P3Nm+9 zryYyof*Vz~NP_53Yt7M9t(z1p>+9~0eXFL7%{W_&i^Eslguz#URtQa6>Ew6H@lNjHzTF3WhECYkL?&~FA^7Lh4y7dcc}RE> zY0H3e#~5Q^Z#YrdL~PNWCRql;zSd{w#np|;gI~XLv@|=nHK!gvdKA{<-AF)5B7_78 zOaA+pf{ioRS{1YV>W3so9(8v|H$MGk3xQleh0oAPY2mc!Zk zKG?A<@$T|Cek9zjAn;(%954lwdsp~+6iYaQ8yz)|DrnhcG#2;rwX(;Q4qDSLNK{X2 ziv`CcKYsy$*Oz7>=j6Ey#&pjO0u^H6s<{l~(}staw!YW<@iNyMOq6bPt^_`@JjBK} zd79|ADXAPoz$qvS2~J&|rOinA6b{AsK40`ND&BU7qu)mW#n}?(S|@IE(IBD7u{q3u ziG#J>{?x%lH$kv>-<=>F8KwL8ArADvPYMG z*!`{^CB1jz`G|-Dkzp8hY?SmRN$2o+(58Y#>+rTie!hWT{V!z4beU@>@-V4*n!v(L z&+8er0df4S#sudSv#9_#K-Tkl1a$K3h$?c70X$wEal z?{d7173vE#85Rsk{mlvu5o6`;W4L*=8>qNX9LLZtHPkA1Z~y~5=sjTFQ@m-C+Ma$) z=_f16do8wpFuFf)eBBZRV<-{j5oAV+M_g~XPFuY1kL|Aj<3X~!*CwlmzOfSGp`QAE zr-)%}fi*Nn$I)GPw;*pNM17ZQ}CWR4?WvQ|aW>Kb>Xs+x4 zF;Rq~15LgN;eg(=)0?j0#??{)mgL>H zDv}7b%9;nyINQcEQ}x^1re&k_!xpZ9_de)zx9lvx6pKJJN~N9~IP{(;3P5*sG~HK5 z4u5#r0HK0JN|oC?nZHX#hir--2lJ6IxM+mK!e?jJ;}{yoUaTPQp&&@63U9#7)Y<7G*GBReh$3x2iwQ*(R~kcaf^#?b_J)T_-S5fVD_ zxTdz`*tZu8F(q4zyu$Xo((#^qR`)$}h9*^X7BFGz=HuOh^&Vn8yiy=F3vV(w|&$4YQ)Li&h0NZ`E^gFR7#!8N8D+ zEx~ke!L{`Pg#l^8BSMUm_!t%%IX;q^l^Kh?JAAEnf-Os|nq>ilV*^L#8e zjTDlS3@#XtEN|$7`(4u<$?3?8)SnQ`N(eAN$|5P_3F`xO-!a}^+xfP0v&XLS-+>K{_Kq2`Cm%VSA_UqF27Sz!6^zxZ8U0DRxTYY1$&TY7q z8mTRt-8!9~jy3b~(2u~%xFai}P3u0o@B5K8N5mRBW9UEP`<7O1iq>zb4E5E#qLdlM z`Txk)N*m|xn#=ENY~exE1PA1&e_GRkdG@PO5EZ+Gy60mT5xb7n=rG?D@WaMFSt`2W z+3I=F%r&eJQG{P=cGwoV?AS#bUR*3SlH=f-d!1x2)sRp1ei^mw=BS5TL4k%vP}fp( zTeU8Y9Q&cG`*b%~vi|-XlcTvAI8)Y?Law2KQUNOL@O1RS##K{QPU!LcRgd>0E1UQ4 z+`xZRSexw<>ln>FJ=5VSvtO{>w7~qU^RBb5QQ)ugY_MrEiF|6h>%S~c#;qQ~r3ww> z(;E(`k$FH^(4X&^eniyAcQYgvpFSTSL72$rqPNZskd+mQ zCHOiM8~G>&N0nmW*)+~^7G}&3O{Tx=#a!P)pXdlvSxJ{A$Uo0p4${B*A$(m%E$4FB z$S^VC{r0#<$4nwja)=ei_x4i^DT+$%^KVTXD8%EFyK`|mME4UekiCFoq!P!4HY z_U4zz12tvh1@NaE!p`v{1KYUcFwi_@%NP#lbI`sn$vP~{j8nS?gKu3xOrcKWb-0*y z%P$MtrH1QZ_@CZn)!*YKj(>jRHM`xvcZa2?|7o`d7a8`F!O}iHgNVR!Sr1CGbD&x7 z6&4d)YmP5f#z0EUOSkIy0a`<%1{U{=1ZDO3^Sa)e_dJd%xlH}7NWsFHTi;ZqBB1tw z8T!V+?O?vOGnm6?i6Yu-^uj1a+TY_JNh%->8hUZ7du`$R7DqfG%=wQFQyv+aeE+B-O#E0=V z&wxRqXh;k!sdQUajZ$i!L`X2Or^YKbo-4k;F^z=rgh-7l?IXdWrpkg~e6wNY63T5c zsmncIaK|17PB`wfd!_&h9cBJE3JA2_VB;2r4f5hAhePL?)c7u!RmIE?$6NKEuf&iD zWW06>c${x-*^6HZfn6`?tK69|MT8ZbR9Tu$FFaik|H%DOM$m==rpvP-Az(O`kq_<94#$A?=E~4 zpCEjCS1R9MyTl4XLn??kRBq@WDixX4lBKG$jc5Wq650 z-*o#%UTiAZ9Vvc(c%VWjrCFH^A&9k^7GpX7jt~+I7<>UA#mFRQc+SaCCChAXwp8n( zU$3pTHu`@A2(kP7(C}lRd_LdGJAS+%U{92S=IrjuIk2}scm0SH`~JoXc8ysHkB5hI zf^|x+#Qq$jH#X^Iz3hqnB#j2|QstwWW`tQz6B^!umFG#OytR!U{@)sf1W5mu7cCz?|DY=-;kO z2CwM44fafAqIj2-PrQC6hPBtT*3RDb52dIRJfJU0BWsO~4=+IK#fa;=A^@TF> zcN>~s<_22~`Iy1wNkXUZ?=Hm4j^|Faef2(3P3wNpZ0#xcY_Z>q zw#q5+1_t#ur9AccZ@O&picC~3Z_LE1%T~6>k2Gs=B#zSTHE~g$j2F`+m*4O}^rR`j z=iI-`PAW9g#pB_qD7)#6)}|D>qvx`L!W@V7SPUavp z%0YfR-$_6G0tGcn$>pqZ&%cu-aP1vv3)eRW&^GfEKRHm(mupMl_Ns#e$y@~{B1&m)#mP(K@ssHoF6Vz7AO4}?uGiBKMeEDQ#^BUWYG^>?x@_8TFV@xld9f~I zK7sRB8|4#9;r>x}9A%jCpIJRZS)E<5|S9mWpW0`$mQme*A4^Skr)r{NF|VU_ZFJHQG@QS88I zN4XZ4k_=6W(58X7gIzW9uw&-MA%aiMs^FxfxTpi!)Y;v?w*NmR;V>@a^&nxq3L+Bx6>YhktZ0%%V%Y4wo;Z#`Y%Lwb!VHa zVfGkybEKJ^;59?+j^SdNa|l5`7$6{i9;2${a@e1gNj@&YoXq(~c1^fv)^qj&wS_@bys-i*Bvpq| zQ%Nb{QMFjXW23&P^nIBHij(uivXle1Z7}L|v=Spe+0Krt!=4Z#N8TIQ=3gMGzB@&} z$mij2n=AE$cm6gP=jI2)vHE}J2?1jC z9rRf^5I&4CZzn||h2=gbk8#g;KdXwPnU7pVxsO-XwY||S8rmU8qiB(;rHO(lehY0* z-_pt_2Y-d@OV1pS7FbhK{O2AFO{T0Nxj9pCqL(aB^KZ(&b=Cum@BEQSV6^xXW%+Eh zN7!^(39NytR?B=0>R>{;6y%zsj;5$8@Xz?~Vf*`pINXOgiwt zK%%0cK>SmGz9uL~+8mwPy~s$3>Sy>2((UX$5Lfe z6ur%;1?E_o$ZZjIMi2AjUq;@RXPo)l-@ZwaX0~jhpwY@V0#tH#{ZBH4U3r^sS853?hpS)N!e?X=dk{_767jIvJ_gN+Y~n1C?In`k?gUijg)3#^U~)+lbMXi7$8}eE4!I5N>g$Ng=g8U4i5#fkEl6JVSC;bLDSy-Qzt}KVvUA*6~ zF{(19%z?bE0RE*D8fbKYJ*fw@lc4TSe9UBhitnWlsJc@kOkIZ1g8sgV>V=n3{o{gc3oav1O#P;6@s(m%FS`5EjRGq?Bn2 zbr|q)J=it>hp6?J-FkC$EUaEfs^d9pO8Vaw*V^1JUyN`6(EJe}O z(mGEPnIKIyiVW8{QTp}h8XN1snM1^aS~j3(*eV}<9<9%b^Xu1dkIUa$hH=xQILB=6 zzY!o5*udop&Mob%!16^W-aSuNq)-~cg9tMMvSXM_9jQ8K$yr)RX1kpr0%^v^CXGnJ zLU7d`AU=Eg_N$*EFeneAcio8dS>DYwwK$aa)f=QvPf|EOlm`(3KZ`}%=9?m;Tf#WUf8z2u+nP>6<@L!qjY=<*2OF;Q^|ANCAJ$jr zR3&$J3{fFlM@10LRj{y-1G(*Gv82;YvqkermVZ&q-J~UBznyrURm&&G5pMM+CWBw- za=Df6Xs9ggPL8*Cjb1~Rw)~go{Z+BT`Fr2&r-V7#-xwa>UpR!pIWOgGa`qx>7SvV( z?>yD*Uog<2Tl};IgN8(^*Wem|pqS|kSXSiB&pEWYUCJoy!I3p}M1Fp*LHO|;nAhIK zHPM}p_>FO7G?$Ul?sx!&Nu>IE)Z9$$8WcKGf}Bl{q`dTLoZ^U)5jAh{O37VB=KCso}9bd_J|RPhC}7|bg664lxFWn&PFF>%> zF87aB&BxOE4M#!(7eiF|^1Smm5gZ!hTpS6_VbNs*Mu?{$d&SJ*(^OH!^f~$_+4sr< z%z9dB)N2OsN~2I{pr3g8dDoa$Y>mK)+bs<`Q9anMGC~m7Wh{i z=-tut78@Ib#}WjQ<|S8{6)8Au-uSJh6ETL}ZMO3?s_pD{6TGtiP*M|sr`f}QvF>Zf zzU+zxTE0IJpC(eS=y~@7V24V*M+yb_jw9?#@(QcRqfF`VS?A)S{*wP3deCB25FQZw zu<*hu9mbO!TfsTc5^7|D#m(_+pv!x^Y_VtT`Ur?imDqFE<>TY=UFGoe3D2)B4uiD! zA(H5dSA-arG-KaIB7)YIE4rRbtzB+$d-pF5LasVDh{0#KIk~?xNJlri{YOj%0R=Az zA^hjnO0p|YrLxBE!vo9J?=6b-G%AtkQPL@^&K_P|(5WdaBx!=X2Qin?}J!FjWPR_l7|**j4* zk!FD^iWrY4I(xnnXD!Pcn_**LV*l?J~8YX(rz3L$W6_W(B zPCUn7(pa|f2=4xWf1EsQ#b&ZKrn=r&YAf7ej#_nvdj{WF3p{eOQtKn!&Acxp=bV4uZ zZg@M7{v+Va1(&C!6f7*Kg>YP-o-|3h^eo_7BxX0NZDLIK>gJS!RZilWx<$tntPl!Z zKGajx8e9=Q!wGKmcbkMqufONa;>}tPI|YH8Zbb`rS7Hdt;iiUf2c;C`@|w~-_)z>7 z*emF1c3m-dd$+Y{y$AIuaZqQix&2PC1xPEKC6Y+XMc0o88+;%$rW( z?C_3D|9m(Rz*3u`g&~P}k%|?y#xK)==mIA%gC9x_Z*W7Cnr6}H2+C@3M=@Fc*u3-; z^7X|mDVl$74fo*{Q-@_c>3eQiqoPJDD_8}o(1pP3Ds(RwHk1Arg_5Py20YeDE66jb zBD*sM_g1$rZ?D?}$R`G>F8VdDXPe!AClQ1VA|tOd$q*^H|0VS8Sv5#|q8~pLQ?5sYrn|8Ct zx0M@rnmqKvO!=`)`QgHyHR>=jq&`YnaK{FpuMb1TNZsX9pU@npt)d*m#MN%u$40L| zd$+$*A(NdjT%)dufK;mZN7)8i&*SP)3tjLp^~PA;Z>g&=ydDxIGYDTJ(tU|}J_uAGyn6hJGX?74tBc8R&u?+$YS?O}QIW*o zjR(FDE_VAOKKY@5s*06#@F7<(BuV}`+Bv@>(f}3pKfG&gu`_vyK;>v{^*qy6!e@_w z54@+8ElK6*@aMO$?Y2Zmkwwz>TD8tPD(UqoFA49MUcg$6L3^Zusn*>%jKwOIIHW)w1;Nc;Lm@Vwnw^wEuS=wTaZG0-y8y*{}AH!O-_*{9@ zBMGNAiUhn#GJ`+V^fV;58rWo`qiE?Ei?w7Bp`Y(fCb#=@xJ;m|e)+NGWpKqt&8ccS zt}h>0mO)xb!j$z;_Inp(5`woeL2k}k0frd~hQ-wlplI!axD0oUVm#+h1nB!qjhz z#@2$Z#KfJtzaF_IvQP^>J4vodRZ0O%T?5FFY7)p6!!Zg z{qkDvoDa472s1M?Xf#$79dR|{SMwcPkh!ERa zYIiK%fOz#sBA&kOaao9*%GJ?{j%u3IvR?NBb^jV2j$Q4hW8wl268>QsR$|M1ukxrg zHwh5R6FR_pemEThp%M~(A@$(gIZWGx^MUALXxE0CIQUsh|=b+$fFnC%MTc2 z_}D}BTI6b6jC1op6ZOKXtwpl!#LHXKAa_yW${*a?dq5m-4;m5I>;jV@$pxKY&owt)rr>MrZ#Rtag--QCz5^rj|T? zv=)g(v||{oOO%R6eYKi0^`%fzluR>kFE7JR z!WQ>Y>US?+Zb)$3 zQ*<~}CMsnAbl6gaWZ{$k{3+JelOf^}aXZso)v76mMEI&qgb&E-GBb_*NTTQRqk4jX<#@dr_atK^3GTsStWM6j>fQSOIGb$DL%U z%lu=VS53C3Qn#~w%VZiTXqctB_@bWr8i~mc%^b8Chj!_;8)>GixMrZ!U8)L}fDSWS zSqyS`6R$G>Sv0z~oECG-7Z1%W>Ckd&%pNc`<9?Sx7*yNYok==r zW=?C4Uc7P^x*~)?Ip@*F3KCbQNU72gBp#~V{G3ZOXs7${B3QihYMiUrvEp3qf(G`i z+%+FG?iO>TRej2}^iiJ{_dR9nNQ8w6I*%Rj7d#vX)euR-hT(t|^#qVmN#n>-N|L!sqIvq%YAaeEguvFpX%|AT+ zNmjN&oE=}H3FVcRnTcxwn`f+GQB(D^cE(-6{x#Gs_`=Nma_YBSd^0dZ5e;;ughFiD zxeIEUsM5d>v*uLl+y_|@N__x-84M@AH*!1|Yb*q>%3^1fwwsrb&m(7s6B-$vXe5*O zCte8)-YFprn2lsT-GyPio)-MjUV`m7MHkzRglQFgrv33C1k^x)I6^R>N_+9VDyP2k zv`NuTAi;uWr;4p(f@ey4HVr|$3}d442}q-KDTGh?M|j&%LRk1h87)yMZ4UomMXF?) z1l6av_DGeBb(IsJ8UbD_c&Fx?qLE3!eiO?=8WeJSHEyEyjB_mXIgXCtqY^_=miJF} z;(4u9J4>Bp3f<`{kZNTe?>B6&d2jV>D|Uu=C8P8@AX$BPi-c(=;CtIDc12tcSL9VWJ~ zlws^*yGHq`=kd(Qm1686@SUCi@nXVU02yhNP)J~~XxpSI-6_;2=0c6GP_H$KEy_(j z)sULHw7=Bh_Wk|sJT=QbrLeHd6#?14HAe6W8@6{OwM&hj%Eg>!`~Y@z?f2g6QVN|^ zagmfMV2VoWV5=(N?cBgEJrv(K`SXA&Hhw(w@!pb zGyB*4ln16w{mc6(HGEbU&&+y`%oS21E0kg}^Jlm{wG<9PT_za<_cxF6OKgOu(R9}` z^3aYN5=`iA?evT*aI)iX0g!8oV>ZrU>GYPVfO_j2j1Gqp*HjA}5?|FhJ%}qCEt9=( z(_h2lLJ3;BLo1&r9XviM3c?6GJ#B#V>*?_L3Ko80{8HHUdYJusv7n{k*|S}oJc*f5 z4J~s1^yxfE)Vo(b>UV!=>EG=qa&duzyB~Ub)(O$6SVMmuO~5FK3?@}pW$GCKr6v3I zE)whPhBTb<(3&%+V{N=3GL|X(7xuGnq7)`f4Z%4Ff|-8}hV5J3Qyb@^R+uCrQsqu( zTOAHL3iVXx;MoqBDmASJM!A-qVOC8DK|`(yBq0#15h5NHMqOQf`GpOd%GWj=Nt+-} z#--ow7zY``HZ!vlv_1VZK$&?*AMLaSa-KBbHk=@msyjt z^V_vX>UV)l?i2Y@ufh#f)AeY(K)v2joHM^2@5

hu|zkrzr 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 D?xx#h literal 0 HcmV?d00001 diff --git a/apprise/var/apprise-failure-72x72.png b/apprise/var/apprise-failure-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..1cb418ba85a68311538388eda3341eb3ade1f5cb GIT binary patch literal 7694 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 IM6N<$g5vPJUH||9 literal 0 HcmV?d00001 diff --git a/apprise/var/apprise-info-128x128.png b/apprise/var/apprise-info-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..b280c6c18081957574a8949a51110d92d4f1664d GIT binary patch literal 16648 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 Uhb86zK?f3ey85}Sb4q9e0BcTv2><{9 literal 0 HcmV?d00001 diff --git a/apprise/var/apprise-info-256x256.png b/apprise/var/apprise-info-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..44df0c7cd2957ce04aad18f5574c52bc11f034f7 GIT binary patch literal 42935 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 literal 0 HcmV?d00001 diff --git a/apprise/var/apprise-info-72x72.png b/apprise/var/apprise-info-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..2f4b8f345954dde8b06f24cdf2c5c02649dcd412 GIT binary patch literal 7933 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 ng4h2V`p+-PsBZi-FYdnqAIe403B&m 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

E5CoSSoe=A4&IyhS0$yJ~H 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

$;oSwfT+Fy=fOf*=&}rTetGaBabjNlxsYE`O9Bs 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 FV1kArBs>5B literal 0 HcmV?d00001 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()