initial commit
|
@ -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',
|
||||
)
|
|
@ -0,0 +1,12 @@
|
|||
"""API properties.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
from apprise import Apprise
|
||||
|
||||
|
||||
def test_initialization():
|
||||
"API: apprise() test initialization"""
|
||||
a = Apprise()
|