@ -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 |
@ -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 |
||||
|
@ -0,0 +1,6 @@
|
||||
include LICENSE |
||||
include README.md |
||||
include requirements.txt |
||||
recursive-include test * |
||||
global-exclude *.pyc |
||||
global-exclude __pycache__ |
@ -0,0 +1,49 @@
|
||||
<hr/> |
||||
|
||||
**ap·prise** / *verb*<br/> |
||||
To inform or tell (someone). To make one aware of something. |
||||
<hr/> |
||||
|
||||
*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<br />boxcar://hostname/@tag<br/>boxcar://hostname/device_token<br />boxcar://hostname/device_token1/device_token2/device_tokenN<br />boxcar://hostname/alias<br />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<br />growl://hostname:portno<br />growl://password@hostname<br />growl://password@hostname:port</br>_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<br />join://apikey/device1/device2/deviceN/<br />join://apikey/group<br />join://apikey/groupA/groupB/groupN<br />join://apikey/DeviceA/groupA/groupN/DeviceN/ |
||||
| [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname<br />kodi://user@hostname<br />kodi://user:password@hostname:port |
||||
| [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// | (TCP) 8065 | mmost://hostname/authkey<br />mmost://hostname:80/authkey<br />mmost://user@hostname:80/authkey<br />mmost://hostname/authkey?channel=channel<br />mmosts://hostname/authkey<br />mmosts://user@hostname/authkey<br /> |
||||
| [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<br />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<br />pbul://accesstoken/#channel<br/>pbul://accesstoken/A_DEVICE_ID<br />pbul://accesstoken/email@address.com<br />pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE |
||||
| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// | (TCP) 80 | pjet://secret<br />pjet://secret@hostname<br />pjet://secret@hostname:port<br />pjets://secret@hostname<br />pjets://secret@hostname:port<br /><i>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<br />pover://user@token/DEVICE<br />pover://user@token/DEVICE1/DEVICE2/DEVICEN<br />_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<br />rockets://user:password@hostname:443/Channel1/Channel1/RoomID<br />rocket://user:password@hostname/Channel |
||||
| [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/Channel<br />slack://botname@TokenA/TokenB/TokenC/Channel<br />slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN |
||||
| [Super Toasty](https://github.com/caronc/apprise/wiki/Notify_toasty) | toasty:// | (TCP) 80 | toasty://user@DEVICE<br />toasty://user@DEVICE1/DEVICE2/DEVICEN<br />_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<br />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<br />xbmc://user@hostname<br />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<br />mailto://domain.com?user=userid&pass=password<br/>mailto://domain.com:2525?user=userid&pass=password<br />mailto://user@gmail.com&pass=password<br />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<br />mailtos://domain.com?user=userid&pass=password<br/>mailtos://domain.com:465?user=userid&pass=password<br />mailtos://user@hotmail.com&pass=password<br />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<br />json://user@hostname<br />json://user:password@hostname:port<br />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<br />xml://user@hostname<br />xml://user:password@hostname:port<br />xml://hostname/a/path/to/post/to |
||||
|
@ -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<protocol>%s://)(bot)?(?P<prefix>([a-z0-9_-]+)' |
||||
r'(:[a-z0-9_-]+)?@)?(?P<btoken_a>[0-9]+):+' |
||||
r'(?P<remaining>.*)$' % '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 |
@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*- |
||||
# |
||||
# A simple collection of general functions |
||||
# |
||||
# Copyright (C) 2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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: |
||||
<schema>://<user>@<host>:<port>/<path> |
||||
<schema>://<user>:<passwd>@<host>:<port>/<path> |
||||
<schema>://<host>:<port>/<path> |
||||
<schema>://<host>/<path> |
||||
<schema>://<host> |
||||
|
||||
Argument parsing is also supported: |
||||
<schema>://<user>@<host>:<port>/<path>?key1=val&key2=val2 |
||||
<schema>://<user>:<passwd>@<host>:<port>/<path>?key1=val&key2=val2 |
||||
<schema>://<host>:<port>/<path>?key1=val&key2=val2 |
||||
<schema>://<host>/<path>?key1=val&key2=val2 |
||||
<schema>://<host>?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))) |
@ -0,0 +1,30 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Supported Push Notifications Libraries |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
from .Apprise import Apprise |
||||
|
||||
__version__ = '0.0.1' |
||||
__author__ = 'Chris Caron <lead2gold@gmail.com>' |
||||
|
||||
__all__ = [ |
||||
# Core |
||||
'Apprise', |
||||
] |
@ -0,0 +1,445 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Base Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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<userid>[a-z0-9!#$%&'*+/=?^_`{|}~-]+" |
||||
r"(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)" |
||||
r"*)@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+" |
||||
r"[a-z0-9](?:[a-z0-9-]*" |
||||
r"[a-z0-9]))?", |
||||
re.IGNORECASE, |
||||
) |
||||
|
||||
|
||||
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'<br />') |
||||
|
||||
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 |
@ -0,0 +1,178 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Boxcar Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,317 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Email Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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<id>[^@]+)@(?P<domain>gmail\.com)$', re.I), |
||||
{ |
||||
'port': 587, |
||||
'smtp_host': 'smtp.gmail.com', |
||||
'secure': True, |
||||
'login_type': (WebBaseLogin.EMAIL, ) |
||||
}, |
||||
), |
||||
|
||||
# Pronto Mail |
||||
( |
||||
'Pronto Mail', |
||||
re.compile('^(?P<id>[^@]+)@(?P<domain>prontomail\.com)$', re.I), |
||||
{ |
||||
'port': 465, |
||||
'smtp_host': 'secure.emailsrvr.com', |
||||
'secure': True, |
||||
'login_type': (WebBaseLogin.EMAIL, ) |
||||
}, |
||||
), |
||||
|
||||
# Microsoft Hotmail |
||||
( |
||||
'Microsoft Hotmail', |
||||
re.compile('^(?P<id>[^@]+)@(?P<domain>(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<id>[^@]+)@(?P<domain>yahoo\.(ca|com))$', re.I), |
||||
{ |
||||
'port': 465, |
||||
'smtp_host': 'smtp.mail.yahoo.com', |
||||
'secure': True, |
||||
'login_type': (WebBaseLogin.EMAIL, ) |
||||
}, |
||||
), |
||||
|
||||
# Catch All |
||||
( |
||||
'Custom', |
||||
re.compile('^(?P<id>[^@]+)@(?P<domain>.+)$', 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 |
@ -0,0 +1,123 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Faast Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,193 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Growl Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,6 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
from . import NotifyGrowl |
||||
|
||||
__all__ = [ |
||||
'NotifyGrowl', |
||||
] |
@ -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() |
@ -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') |
@ -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/<version> <messagetype> <encryptionAlgorithmID>[:<ivValue>][ <keyHashAlgorithmID>:<keyHash>.<salt>] |
||||
GNTP_INFO_LINE = re.compile( |
||||
'GNTP/(?P<version>\d+\.\d+) (?P<messagetype>REGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)' + |
||||
' (?P<encryptionAlgorithmID>[A-Z0-9]+(:(?P<ivValue>[A-F0-9]+))?) ?' + |
||||
'((?P<keyHashAlgorithmID>[A-Z0-9]+):(?P<keyHash>[A-F0-9]+).(?P<salt>[A-F0-9]+))?\r\n', |
||||
re.IGNORECASE |
||||
) |
||||
|
||||
GNTP_INFO_LINE_SHORT = re.compile( |
||||
'GNTP/(?P<version>\d+\.\d+) (?P<messagetype>REGISTER|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') |
@ -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" |
@ -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 <http://code.google.com/p/growl/source/browse/Bindings/python/Growl.py>`_ |
||||
|
||||
""" |
||||
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') |
@ -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" |
@ -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' |
@ -0,0 +1,136 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# JSON Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,212 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Join Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
# 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<name>(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 |
@ -0,0 +1,172 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# MatterMost Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,173 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Notify My Android (NMA) Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,177 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Prowl Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,167 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# PushBullet Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,146 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Pushalot Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,76 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Pushjet Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,76 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Pushjet Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,6 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
from . import NotifyPushjet |
||||
|
||||
__all__ = [ |
||||
'NotifyPushjet', |
||||
] |
@ -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 |
@ -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 <http://docs.python-requests.org>`__ |
||||
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 |
||||
<https://github.com/Pushjet/Pushjet-Server-Api/issues>`__. |
||||
""" |
@ -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 "<Pushjet Service: \"{}\">".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 "<Pushjet Device: {}>".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 "<Pushjet Subscription to service \"{}\">".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 "<Pushjet Message: \"{}\">".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 "<Pushjet Api: {}>".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 |
||||
|
@ -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 |
@ -0,0 +1,222 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Pushover Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,307 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Notify Rocket.Chat Notify Wrapper |
||||
# |
||||
# Copyright (C) 2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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<name>[A-Za-z0-9]+)$') |
||||
IS_ROOM_ID = re.compile(r'^(?P<name>[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 |
@ -0,0 +1,287 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Slack Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
# 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 |
@ -0,0 +1,412 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Telegram Notify Wrapper |
||||
# |
||||
# Copyright (C) 2016-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
# 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<key>[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<idno>-?[0-9]{1,32})|(?P<name>[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'] = '<b>%s</b>\r\n%s' % (title, body) |
||||
|
||||
else: |
||||
# Text |
||||
# payload['parse_mode'] = 'Markdown' |
||||
payload['parse_mode'] = 'HTML' |
||||
payload['text'] = '<b>%s</b>\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 |
@ -0,0 +1,155 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# (Super) Toasty Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,116 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Twitter Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,6 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
from . import NotifyTwitter |
||||
|
||||
__all__ = [ |
||||
'NotifyTwitter', |
||||
] |
@ -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 |
@ -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) |
@ -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 |
@ -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) |
@ -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] |
@ -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 |
@ -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 |
@ -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 |
@ -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<enc>\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 |
@ -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]) |
@ -0,0 +1,221 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# XBMC Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 |
@ -0,0 +1,154 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# XML Notify Wrapper |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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 = """<?xml version='1.0' encoding='utf-8'?> |
||||
<soapenv:Envelope |
||||
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" |
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema" |
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> |
||||
<soapenv:Body> |
||||
<Notification xmlns:xsi="http://nuxref.com/apprise/NotifyXML-1.0.xsd"> |
||||
<Version>1.0</Version> |
||||
<Subject>{SUBJECT}</Subject> |
||||
<MessageType>{MESSAGE_TYPE}</MessageType> |
||||
<Message>{MESSAGE}</Message> |
||||
</Notification> |
||||
</soapenv:Body> |
||||
</soapenv:Envelope>""" |
||||
|
||||
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 |
@ -0,0 +1,50 @@
|
||||
# -*- encoding: utf-8 -*- |
||||
# |
||||
# Our service wrappers |
||||
# |
||||
# Copyright (C) 2014-2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
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' |
||||
] |
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
||||
<xs:element name="Notification"> |
||||
<xs:complexType> |
||||
<xs:sequence> |
||||
<xs:element name="Version" type="xs:string" /> |
||||
<xs:element name="MessageType" type="xs:string" /> |
||||
<xs:simpleType> |
||||
<xs:restriction base="xs:string"> |
||||
<xs:enumeration value="success" /> |
||||
<xs:enumeration value="failure" /> |
||||
<xs:enumeration value="info" /> |
||||
<xs:enumeration value="warning" /> |
||||
</xs:restriction> |
||||
</xs:simpleType> |
||||
</xs:element> |
||||
<xs:element name="Subject" type="xs:string" /> |
||||
<xs:element name="Message" type="xs:string" /> |
||||
</xs:sequence> |
||||
</xs:complexType> |
||||
</xs:element> |
||||
</xs:schema> |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 7.5 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 7.7 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 7.7 KiB |
@ -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() |
@ -0,0 +1,9 @@
|
||||
chardet |
||||
markdown |
||||
decorator |
||||
requests |
||||
requests-oauthlib |
||||
oauthlib |
||||
urllib3 |
||||
six |
||||
click |
@ -0,0 +1,4 @@
|
||||
[egg_info] |
||||
tag_build = |
||||
tag_date = 0 |
||||
tag_svn_revision = 0 |
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python |
||||
# -*- coding: utf-8 -*- |
||||
# |
||||
# SetupTools Script |
||||
# |
||||
# Copyright (C) 2017 Chris Caron <lead2gold@gmail.com> |
||||
# |
||||
# 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', |
||||
) |