diff --git a/Headphones.py b/Headphones.py index 19cc82dd..5b4a42de 100755 --- a/Headphones.py +++ b/Headphones.py @@ -14,7 +14,12 @@ # You should have received a copy of the GNU General Public License # along with Headphones. If not, see . -import os, sys, locale +import os, sys + +# Ensure lib added to path, before any other imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/')) + +import locale import time import signal @@ -34,7 +39,6 @@ signal.signal(signal.SIGTERM, headphones.sig_handler) def main(): - # Fixed paths to Headphones if hasattr(sys, 'frozen'): headphones.FULL_PATH = os.path.abspath(sys.executable) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 8fa9f0c3..3f9115a5 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -528,6 +528,23 @@
+
+

Growl

+
+ +
+
+
+ +
+
+ +
+
+ +
+
+

Prowl

@@ -1192,6 +1209,26 @@ } }); + if ($("#growl").is(":checked")) + { + $("#growloptions").show(); + } + else + { + $("#growloptions").hide(); + } + + $("#growl").click(function(){ + if ($("#growl").is(":checked")) + { + $("#growloptions").slideDown(); + } + else + { + $("#growloptions").slideUp(); + } + }); + if ($("#prowl").is(":checked")) { $("#prowloptions").show(); diff --git a/headphones/__init__.py b/headphones/__init__.py index 286c59f4..fbf0dddf 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -221,6 +221,10 @@ ENCODERLOSSLESS = False ENCODER_MULTICORE = False ENCODER_MULTICORE_COUNT = 0 DELETE_LOSSLESS_FILES = False +GROWL_ENABLED = True +GROWL_HOST = None +GROWL_PASSWORD = None +GROWL_ONSNATCH = True PROWL_ENABLED = True PROWL_PRIORITY = 1 PROWL_KEYS = None @@ -341,7 +345,7 @@ def initialize(): NZB_DOWNLOADER, TORRENT_DOWNLOADER, PREFERRED_WORDS, REQUIRED_WORDS, IGNORED_WORDS, LASTFM_USERNAME, \ INTERFACE, FOLDER_PERMISSIONS, FILE_PERMISSIONS, ENCODERFOLDER, ENCODER_PATH, ENCODER, XLDPROFILE, BITRATE, SAMPLINGFREQUENCY, \ MUSIC_ENCODER, ADVANCEDENCODER, ENCODEROUTPUTFORMAT, ENCODERQUALITY, ENCODERVBRCBR, ENCODERLOSSLESS, ENCODER_MULTICORE, ENCODER_MULTICORE_COUNT, DELETE_LOSSLESS_FILES, \ - PROWL_ENABLED, PROWL_PRIORITY, PROWL_KEYS, PROWL_ONSNATCH, PUSHOVER_ENABLED, PUSHOVER_PRIORITY, PUSHOVER_KEYS, PUSHOVER_ONSNATCH, PUSHOVER_APITOKEN, MIRRORLIST, \ + GROWL_ENABLED, GROWL_HOST, GROWL_PASSWORD, GROWL_ONSNATCH, PROWL_ENABLED, PROWL_PRIORITY, PROWL_KEYS, PROWL_ONSNATCH, PUSHOVER_ENABLED, PUSHOVER_PRIORITY, PUSHOVER_KEYS, PUSHOVER_ONSNATCH, PUSHOVER_APITOKEN, MIRRORLIST, \ TWITTER_ENABLED, TWITTER_ONSNATCH, TWITTER_USERNAME, TWITTER_PASSWORD, TWITTER_PREFIX, \ PUSHBULLET_ENABLED, PUSHBULLET_APIKEY, PUSHBULLET_DEVICEID, PUSHBULLET_ONSNATCH, \ MIRROR, CUSTOMHOST, CUSTOMPORT, CUSTOMSLEEP, HPUSER, HPPASS, XBMC_ENABLED, XBMC_HOST, XBMC_USERNAME, XBMC_PASSWORD, XBMC_UPDATE, \ @@ -368,6 +372,7 @@ def initialize(): CheckSection('Waffles') CheckSection('Rutracker') CheckSection('What.cd') + CheckSection('Growl') CheckSection('Prowl') CheckSection('Pushover') CheckSection('PushBullet') @@ -541,6 +546,11 @@ def initialize(): ENCODER_MULTICORE_COUNT = max(0, check_setting_int(CFG, 'General', 'encoder_multicore_count', 0)) DELETE_LOSSLESS_FILES = bool(check_setting_int(CFG, 'General', 'delete_lossless_files', 1)) + GROWL_ENABLED = bool(check_setting_int(CFG, 'Growl', 'growl_enabled', 0)) + GROWL_HOST = check_setting_str(CFG, 'Growl', 'growl_host', '') + GROWL_PASSWORD = check_setting_str(CFG, 'Growl', 'growl_password', '') + GROWL_ONSNATCH = bool(check_setting_int(CFG, 'Growl', 'growl_onsnatch', 0)) + PROWL_ENABLED = bool(check_setting_int(CFG, 'Prowl', 'prowl_enabled', 0)) PROWL_KEYS = check_setting_str(CFG, 'Prowl', 'prowl_keys', '') PROWL_ONSNATCH = bool(check_setting_int(CFG, 'Prowl', 'prowl_onsnatch', 0)) @@ -934,6 +944,12 @@ def config_write(): new_config['General']['ignored_words'] = IGNORED_WORDS new_config['General']['required_words'] = REQUIRED_WORDS + new_config['Growl'] = {} + new_config['Growl']['growl_enabled'] = int(GROWL_ENABLED) + new_config['Growl']['growl_host'] = GROWL_HOST + new_config['Growl']['growl_password'] = GROWL_PASSWORD + new_config['Growl']['growl_onsnatch'] = int(GROWL_ONSNATCH) + new_config['Prowl'] = {} new_config['Prowl']['prowl_enabled'] = int(PROWL_ENABLED) new_config['Prowl']['prowl_keys'] = PROWL_KEYS diff --git a/headphones/notifiers.py b/headphones/notifiers.py index 8b204019..c43b2541 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -24,6 +24,7 @@ from httplib import HTTPSConnection from urllib import urlencode import os.path import subprocess +import gntp.notifier import lib.simplejson as simplejson from xml.dom import minidom @@ -35,6 +36,82 @@ except: import lib.oauth2 as oauth import lib.pythontwitter as twitter +class GROWL: + + def __init__(self): + self.enabled = headphones.GROWL_ENABLED + self.host = headphones.GROWL_HOST + self.password = headphones.GROWL_PASSWORD + + def conf(self, options): + return cherrypy.config['config'].get('Growl', options) + + def notify(self, message, event): + if not self.enabled: + return + + # Split host and port + if self.host == "": + host, port = "localhost", 23053 + if ":" in self.host: + host, port = self.host.split(':', 1) + port = int(port) + else: + host, port = self.host, 23053 + + # If password is empty, assume none + if self.password == "": + password = None + else: + password = self.password + + # Register notification + growl = gntp.notifier.GrowlNotifier( + applicationName='Headphones', + notifications=['New Event'], + defaultNotifications=['New Event'], + hostname=host, + port=port, + password=password + ) + + try: + growl.register() + except gntp.notifier.errors.NetworkError: + logger.info(u'Growl notification failed: network error') + return + except gntp.notifier.errors.AuthError: + logger.info(u'Growl notification failed: authentication error') + return + + # Send it, including an image + image_file = os.path.join(str(headphones.PROG_DIR), 'data/images/headphoneslogo.png') + image = open(image_file, 'rb').read() + + try: + growl.notify( + noteType='New Event', + title=event, + description=message, + icon=image + ) + except gntp.notifier.errors.NetworkError: + logger.info(u'Growl notification failed: network error') + return + + logger.info(u"Growl notifications sent.") + + def updateLibrary(self): + #For uniformity reasons not removed + return + + def test(self, host, password): + self.enabled = True + self.host = host + self.password = password + + self.notify('ZOMG Lazors Pewpewpew!', 'Test Message') + class PROWL: keys = [] @@ -44,7 +121,6 @@ class PROWL: self.enabled = headphones.PROWL_ENABLED self.keys = headphones.PROWL_KEYS self.priority = headphones.PROWL_PRIORITY - pass def conf(self, options): return cherrypy.config['config'].get('Prowl', options) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 0de3a75a..981c7e77 100644 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -407,6 +407,12 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, logger.info(u'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle'])) + if headphones.GROWL_ENABLED: + pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle'] + logger.info(u"Growl request") + growl = notifiers.GROWL() + growl.notify(pushmessage,"Download and Postprocessing completed") + if headphones.PROWL_ENABLED: pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle'] logger.info(u"Prowl request") diff --git a/headphones/sab.py b/headphones/sab.py index 45b494a6..2bcbe2d2 100644 --- a/headphones/sab.py +++ b/headphones/sab.py @@ -119,6 +119,10 @@ def sendNZB(nzb): if sabText == "ok": logger.info(u"NZB sent to SAB successfully") + if headphones.GROWL_ENABLED and headphones.GROWL_ONSNATCH: + logger.info(u"Sending Growl notification") + growl = notifiers.GROWL() + growl.notify(nzb.name,"Download started") if headphones.PROWL_ENABLED and headphones.PROWL_ONSNATCH: logger.info(u"Sending Prowl notification") prowl = notifiers.PROWL() diff --git a/headphones/transmission.py b/headphones/transmission.py index 55449290..cfb151c6 100644 --- a/headphones/transmission.py +++ b/headphones/transmission.py @@ -49,6 +49,10 @@ def addTorrent(link): retid = False logger.info(u"Torrent sent to Transmission successfully") + if headphones.GROWL_ENABLED and headphones.GROWL_ONSNATCH: + logger.info(u"Sending Growl notification") + growl = notifiers.GROWL() + growl.notify(name,"Download started") if headphones.PROWL_ENABLED and headphones.PROWL_ONSNATCH: logger.info(u"Sending Prowl notification") prowl = notifiers.PROWL() diff --git a/headphones/webserve.py b/headphones/webserve.py index 6532c121..2866f452 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -933,6 +933,10 @@ class WebInterface(object): "encoder_multicore": checked(headphones.ENCODER_MULTICORE), "encoder_multicore_count": int(headphones.ENCODER_MULTICORE_COUNT), "delete_lossless_files": checked(headphones.DELETE_LOSSLESS_FILES), + "growl_enabled": checked(headphones.GROWL_ENABLED), + "growl_onsnatch": checked(headphones.GROWL_ONSNATCH), + "growl_host": headphones.GROWL_HOST, + "growl_password": headphones.GROWL_PASSWORD, "prowl_enabled": checked(headphones.PROWL_ENABLED), "prowl_onsnatch": checked(headphones.PROWL_ONSNATCH), "prowl_keys": headphones.PROWL_KEYS, @@ -1013,7 +1017,7 @@ class WebInterface(object): destination_dir=None, lossless_destination_dir=None, folder_format=None, file_format=None, file_underscores=0, include_extras=0, single=0, ep=0, compilation=0, soundtrack=0, live=0, remix=0, spokenword=0, audiobook=0, autowant_upcoming=False, autowant_all=False, keep_torrent_files=False, interface=None, log_dir=None, cache_dir=None, music_encoder=0, encoder=None, xldprofile=None, bitrate=None, samplingfrequency=None, encoderfolder=None, advancedencoder=None, encoderoutputformat=None, encodervbrcbr=None, encoderquality=None, encoderlossless=0, - delete_lossless_files=0, prowl_enabled=0, prowl_onsnatch=0, prowl_keys=None, prowl_priority=0, xbmc_enabled=0, xbmc_host=None, xbmc_username=None, xbmc_password=None, + delete_lossless_files=0, growl_enabled=0, growl_onsnatch=0, growl_host=None, growl_password=None, prowl_enabled=0, prowl_onsnatch=0, prowl_keys=None, prowl_priority=0, xbmc_enabled=0, xbmc_host=None, xbmc_username=None, xbmc_password=None, xbmc_update=0, xbmc_notify=0, nma_enabled=False, nma_apikey=None, nma_priority=0, nma_onsnatch=0, pushalot_enabled=False, pushalot_apikey=None, pushalot_onsnatch=0, synoindex_enabled=False, pushover_enabled=0, pushover_onsnatch=0, pushover_keys=None, pushover_priority=0, pushover_apitoken=None, pushbullet_enabled=0, pushbullet_onsnatch=0, pushbullet_apikey=None, pushbullet_deviceid=None, twitter_enabled=0, twitter_onsnatch=0, mirror=None, customhost=None, customport=None, customsleep=None, hpuser=None, hppass=None, preferred_bitrate_high_buffer=None, preferred_bitrate_low_buffer=None, preferred_bitrate_allow_lossless=0, cache_sizemb=None, @@ -1129,6 +1133,10 @@ class WebInterface(object): headphones.ENCODER_MULTICORE = encoder_multicore headphones.ENCODER_MULTICORE_COUNT = max(0, int(encoder_multicore_count)) headphones.DELETE_LOSSLESS_FILES = int(delete_lossless_files) + headphones.GROWL_ENABLED = growl_enabled + headphones.GROWL_ONSNATCH = growl_onsnatch + headphones.GROWL_HOST = growl_host + headphones.GROWL_PASSWORD = growl_password headphones.PROWL_ENABLED = prowl_enabled headphones.PROWL_ONSNATCH = prowl_onsnatch headphones.PROWL_KEYS = prowl_keys diff --git a/lib/gntp/LICENSE b/lib/gntp/LICENSE new file mode 100644 index 00000000..c274a5a2 --- /dev/null +++ b/lib/gntp/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2013 Paul Traylor + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/gntp/__init__.py b/lib/gntp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lib/gntp/cli.py b/lib/gntp/cli.py new file mode 100644 index 00000000..bc083062 --- /dev/null +++ b/lib/gntp/cli.py @@ -0,0 +1,141 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +import logging +import os +import sys +from optparse import OptionParser, OptionGroup + +from gntp.notifier import GrowlNotifier +from gntp.shim import RawConfigParser +from gntp.version import __version__ + +DEFAULT_CONFIG = os.path.expanduser('~/.gntp') + +config = RawConfigParser({ + 'hostname': 'localhost', + 'password': None, + 'port': 23053, +}) +config.read([DEFAULT_CONFIG]) +if not config.has_section('gntp'): + config.add_section('gntp') + + +class ClientParser(OptionParser): + def __init__(self): + OptionParser.__init__(self, version="%%prog %s" % __version__) + + group = OptionGroup(self, "Network Options") + group.add_option("-H", "--host", + dest="host", default=config.get('gntp', 'hostname'), + help="Specify a hostname to which to send a remote notification. [%default]") + group.add_option("--port", + dest="port", default=config.getint('gntp', 'port'), type="int", + help="port to listen on [%default]") + group.add_option("-P", "--password", + dest='password', default=config.get('gntp', 'password'), + help="Network password") + self.add_option_group(group) + + group = OptionGroup(self, "Notification Options") + group.add_option("-n", "--name", + dest="app", default='Python GNTP Test Client', + help="Set the name of the application [%default]") + group.add_option("-s", "--sticky", + dest='sticky', default=False, action="store_true", + help="Make the notification sticky [%default]") + group.add_option("--image", + dest="icon", default=None, + help="Icon for notification (URL or /path/to/file)") + group.add_option("-m", "--message", + dest="message", default=None, + help="Sets the message instead of using stdin") + group.add_option("-p", "--priority", + dest="priority", default=0, type="int", + help="-2 to 2 [%default]") + group.add_option("-d", "--identifier", + dest="identifier", + help="Identifier for coalescing") + group.add_option("-t", "--title", + dest="title", default=None, + help="Set the title of the notification [%default]") + group.add_option("-N", "--notification", + dest="name", default='Notification', + help="Set the notification name [%default]") + group.add_option("--callback", + dest="callback", + help="URL callback") + self.add_option_group(group) + + # Extra Options + self.add_option('-v', '--verbose', + dest='verbose', default=0, action='count', + help="Verbosity levels") + + def parse_args(self, args=None, values=None): + values, args = OptionParser.parse_args(self, args, values) + + if values.message is None: + print('Enter a message followed by Ctrl-D') + try: + message = sys.stdin.read() + except KeyboardInterrupt: + exit() + else: + message = values.message + + if values.title is None: + values.title = ' '.join(args) + + # If we still have an empty title, use the + # first bit of the message as the title + if values.title == '': + values.title = message[:20] + + values.verbose = logging.WARNING - values.verbose * 10 + + return values, message + + +def main(): + (options, message) = ClientParser().parse_args() + logging.basicConfig(level=options.verbose) + if not os.path.exists(DEFAULT_CONFIG): + logging.info('No config read found at %s', DEFAULT_CONFIG) + + growl = GrowlNotifier( + applicationName=options.app, + notifications=[options.name], + defaultNotifications=[options.name], + hostname=options.host, + password=options.password, + port=options.port, + ) + result = growl.register() + if result is not True: + exit(result) + + # This would likely be better placed within the growl notifier + # class but until I make _checkIcon smarter this is "easier" + if options.icon is not None and not options.icon.startswith('http'): + logging.info('Loading image %s', options.icon) + f = open(options.icon) + options.icon = f.read() + f.close() + + result = growl.notify( + noteType=options.name, + title=options.title, + description=message, + icon=options.icon, + sticky=options.sticky, + priority=options.priority, + callback=options.callback, + identifier=options.identifier, + ) + if result is not True: + exit(result) + +if __name__ == "__main__": + main() diff --git a/lib/gntp/config.py b/lib/gntp/config.py new file mode 100644 index 00000000..7536bd14 --- /dev/null +++ b/lib/gntp/config.py @@ -0,0 +1,77 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +The gntp.config module is provided as an extended GrowlNotifier object that takes +advantage of the ConfigParser module to allow us to setup some default values +(such as hostname, password, and port) in a more global way to be shared among +programs using gntp +""" +import logging +import os + +import gntp.notifier +import gntp.shim + +__all__ = [ + 'mini', + 'GrowlNotifier' +] + +logger = logging.getLogger(__name__) + + +class GrowlNotifier(gntp.notifier.GrowlNotifier): + """ + ConfigParser enhanced GrowlNotifier object + + For right now, we are only interested in letting users overide certain + values from ~/.gntp + + :: + + [gntp] + hostname = ? + password = ? + port = ? + """ + def __init__(self, *args, **kwargs): + config = gntp.shim.RawConfigParser({ + 'hostname': kwargs.get('hostname', 'localhost'), + 'password': kwargs.get('password'), + 'port': kwargs.get('port', 23053), + }) + + config.read([os.path.expanduser('~/.gntp')]) + + # If the file does not exist, then there will be no gntp section defined + # and the config.get() lines below will get confused. Since we are not + # saving the config, it should be safe to just add it here so the + # code below doesn't complain + if not config.has_section('gntp'): + logger.info('Error reading ~/.gntp config file') + config.add_section('gntp') + + kwargs['password'] = config.get('gntp', 'password') + kwargs['hostname'] = config.get('gntp', 'hostname') + kwargs['port'] = config.getint('gntp', 'port') + + super(GrowlNotifier, self).__init__(*args, **kwargs) + + +def mini(description, **kwargs): + """Single notification function + + Simple notification function in one line. Has only one required parameter + and attempts to use reasonable defaults for everything else + :param string description: Notification message + """ + kwargs['notifierFactory'] = GrowlNotifier + gntp.notifier.mini(description, **kwargs) + + +if __name__ == '__main__': + # If we're running this module directly we're likely running it as a test + # so extra debugging is useful + logging.basicConfig(level=logging.INFO) + mini('Testing mini notification') diff --git a/lib/gntp/core.py b/lib/gntp/core.py new file mode 100644 index 00000000..ee544d3d --- /dev/null +++ b/lib/gntp/core.py @@ -0,0 +1,511 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +import hashlib +import re +import time + +import gntp.shim +import gntp.errors as errors + +__all__ = [ + 'GNTPRegister', + 'GNTPNotice', + 'GNTPSubscribe', + 'GNTPOK', + 'GNTPError', + 'parse_gntp', +] + +#GNTP/ [:][ :.] +GNTP_INFO_LINE = re.compile( + 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)' + + ' (?P[A-Z0-9]+(:(?P[A-F0-9]+))?) ?' + + '((?P[A-Z0-9]+):(?P[A-F0-9]+).(?P[A-F0-9]+))?\r\n', + re.IGNORECASE +) + +GNTP_INFO_LINE_SHORT = re.compile( + 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)', + re.IGNORECASE +) + +GNTP_HEADER = re.compile('([\w-]+):(.+)') + +GNTP_EOL = gntp.shim.b('\r\n') +GNTP_SEP = gntp.shim.b(': ') + + +class _GNTPBuffer(gntp.shim.StringIO): + """GNTP Buffer class""" + def writeln(self, value=None): + if value: + self.write(gntp.shim.b(value)) + self.write(GNTP_EOL) + + def writeheader(self, key, value): + if not isinstance(value, str): + value = str(value) + self.write(gntp.shim.b(key)) + self.write(GNTP_SEP) + self.write(gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.shim.u(data) + match = GNTP_INFO_LINE_SHORT.match(data) + if not match: + raise errors.ParseError('INVALID_GNTP_INFO') + info = match.groupdict() + if info['messagetype'] == 'REGISTER': + return GNTPRegister(data, password=password) + elif info['messagetype'] == 'NOTIFY': + return GNTPNotice(data, password=password) + elif info['messagetype'] == 'SUBSCRIBE': + return GNTPSubscribe(data, password=password) + elif info['messagetype'] == '-OK': + return GNTPOK(data) + elif info['messagetype'] == '-ERROR': + return GNTPError(data) + raise errors.ParseError('INVALID_GNTP_MESSAGE') diff --git a/lib/gntp/errors.py b/lib/gntp/errors.py new file mode 100644 index 00000000..c006fd68 --- /dev/null +++ b/lib/gntp/errors.py @@ -0,0 +1,25 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +class BaseError(Exception): + pass + + +class ParseError(BaseError): + errorcode = 500 + errordesc = 'Error parsing the message' + + +class AuthError(BaseError): + errorcode = 400 + errordesc = 'Error with authorization' + + +class UnsupportedError(BaseError): + errorcode = 500 + errordesc = 'Currently unsupported by gntp.py' + + +class NetworkError(BaseError): + errorcode = 500 + errordesc = "Error connecting to growl server" diff --git a/lib/gntp/notifier.py b/lib/gntp/notifier.py new file mode 100644 index 00000000..1719ecdf --- /dev/null +++ b/lib/gntp/notifier.py @@ -0,0 +1,265 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +The gntp.notifier module is provided as a simple way to send notifications +using GNTP + +.. note:: + This class is intended to mostly mirror the older Python bindings such + that you should be able to replace instances of the old bindings with + this class. + `Original Python bindings `_ + +""" +import logging +import platform +import socket +import sys + +from gntp.version import __version__ +import gntp.core +import gntp.errors as errors +import gntp.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 gntp.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 = gntp.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 = gntp.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 = gntp.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(gntp.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 = gntp.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) == gntp.core.GNTPOK: + return True + logger.error('Invalid response: %s', response.error()) + return response.error() + + +def mini(description, applicationName='PythonMini', noteType="Message", + title="Mini Message", applicationIcon=None, hostname='localhost', + password=None, port=23053, sticky=False, priority=None, + callback=None, notificationIcon=None, identifier=None, + notifierFactory=GrowlNotifier): + """Single notification function + + Simple notification function in one line. Has only one required parameter + and attempts to use reasonable defaults for everything else + :param string description: Notification message + + .. warning:: + For now, only URL callbacks are supported. In the future, the + callback argument will also support a function + """ + try: + growl = notifierFactory( + applicationName=applicationName, + notifications=[noteType], + defaultNotifications=[noteType], + applicationIcon=applicationIcon, + hostname=hostname, + password=password, + port=port, + ) + result = growl.register() + if result is not True: + return result + + return growl.notify( + noteType=noteType, + title=title, + description=description, + icon=notificationIcon, + sticky=sticky, + priority=priority, + callback=callback, + identifier=identifier, + ) + except Exception: + # We want the "mini" function to be simple and swallow Exceptions + # in order to be less invasive + logger.exception("Growl error") + +if __name__ == '__main__': + # If we're running this module directly we're likely running it as a test + # so extra debugging is useful + logging.basicConfig(level=logging.INFO) + mini('Testing mini notification') diff --git a/lib/gntp/shim.py b/lib/gntp/shim.py new file mode 100644 index 00000000..3a387828 --- /dev/null +++ b/lib/gntp/shim.py @@ -0,0 +1,45 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +""" +Python2.5 and Python3.3 compatibility shim + +Heavily inspirted by the "six" library. +https://pypi.python.org/pypi/six +""" + +import sys + +PY3 = sys.version_info[0] == 3 + +if PY3: + def b(s): + if isinstance(s, bytes): + return s + return s.encode('utf8', 'replace') + + def u(s): + if isinstance(s, bytes): + return s.decode('utf8', 'replace') + return s + + from io import BytesIO as StringIO + from configparser import RawConfigParser +else: + def b(s): + if isinstance(s, unicode): + return s.encode('utf8', 'replace') + return s + + def u(s): + if isinstance(s, unicode): + return s + if isinstance(s, int): + s = str(s) + return unicode(s, "utf8", "replace") + + from StringIO import StringIO + from ConfigParser import RawConfigParser + +b.__doc__ = "Ensure we have a byte string" +u.__doc__ = "Ensure we have a unicode string" diff --git a/lib/gntp/version.py b/lib/gntp/version.py new file mode 100644 index 00000000..2166aaca --- /dev/null +++ b/lib/gntp/version.py @@ -0,0 +1,4 @@ +# Copyright: 2013 Paul Traylor +# These sources are released under the terms of the MIT license: see LICENSE + +__version__ = '1.0.2'