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