#!/usr/bin/python -tt # TinyFugue notifier v1.5.2 # Copyright (c) 2008, Matthew J. Ernisse # Copyright (c) 2008-9, 2011, John Morrissey # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Change history: # v1.5.2 (10 Sep 2011): # - Fix before/after comparisons: compare against the current time instead # of always assuming it's midnight. # - More resilient detection of attached/detached screen state. # # v1.5.1 (9 Sep 2011): # - Assume the screen containing tf is detached if no screen is found. # # v1.5 (19 Sep 2009): # - Add prowl (http://prowl.weks.net/) notification support. # - Add 'when' before/after support, to only send notifications # during certain time or date intervals. # - 'when' items in the notification configuration are now dictionaries # instead of lists, so comparison values can be specified for # conditions. Though deprecated, the list syntax is still accepted, # so all existing configurations should remain compatible. # - Make the log polling loop more readable by splitting the # 'when' condition matching code and notification sending code # into separate functions. # # v1.4.4 (7 Jul 2009): # - Don't send matching lines to multiple notification instances with # identical 'method' settings. This avoids duplicate notifications when # notification instances are identical except for different 'when' # settings, such as when ORing 'when' conditions. # - Check for new log lines more frequently (every two seconds, was five). # # v1.4.3 (6 Jun 2009): # - Add 'when' detached support. # # v1.4.2 (12 Jan 2009): # - Add support for notification configuration in ~/.tf-notifierrc. # # v1.4.1: # - Handle log file rotation gracefully. # - Catch getopt exceptions while parsing .tfrc /def entries. # - Small readability improvements. # # v1.4: # - Added 'when' backlogged support. # - Greatly improved notification channel documentation. # - Make certain method configuration directives optional and choose useful # defaults. # - Enforce the presence of required method configuration directives (at # startup) instead of crashing and burning later. # # v1.3.1: # - Initial public release. # Notification channel configuration: # # If the NOTIFICATION array below is empty, ~/.tf-notifierrc will be # examined for a NOTIFICATION array. # # - notify_after: optional number of seconds to wait after a message is # received before sending a notification via this channel. Useful for # batching notifications together (sending more matched lines in each # notification that is triggered). Defaults to 0 (notify immediately). # - when: optional array limiting when notifications should be sent. # conditions are ANDed together. # - after: only send notifications received after this date or time. # values must be a datetime.datetime or datetime.time object. # The datetime module is imported automatically before # ~/.tf-notifierrc is read. # - backlogged: only send notifications when backlogged (i.e., the # world is paused and "More" is displayed in the status bar). # Requires these hooks in .tfrc: # # /hook MORE = /sys chmod -t ~/.tf.log # /hook NOMORE = /sys chmod +t ~/.tf.log # # TinyFugue as of 5.0beta8 (the latest available as of this writing), # needs patching (http://horde.net/~jwm/software/misc/tf-nomore.patch) # to add the NOMORE hook. # - before: only send notifications received before this date or time. # values must be a datetime.datetime or datetime.time object. # The datetime module is imported automatically before # ~/.tf-notifierrc is read. # - detached: only send notifications when the screen(1) containing # TinyFugue is detached. If TinyFugue is not being run in a screen, # or the screen's status can't be determined, notification is # performed anyway. Required lsof(1). # - method: required method for sending notifications. A notification # method can have multiple channels defined. See below for list of # supported methods and information on their configuration. # # Methods: # - growl # Sends notifications to a remote Mac OS X Growl (http://growl.info/) # server. # - appname: optional application name. Defaults to the basename of this # script (usually 'tf-notifier'). # - title: required notification title. # - host: required Growl server hostname or address. # - password: required Growl server password. # # - libnotify # Sends notifications to Gnome's notification framework. # - title: required notification title. # - urgency: optional notification urgency. Possible values are 'low', # 'normal', and 'urgent'. Defaults to 'low'. # # - prowl # Sends notifications to Prowl (http://prowl.weks.net/). # - apikey: required Prowl API key. # - appname: optional application name. Defaults to the basename of this # script (usually 'tf-notifier'). # - title: required notification title. # # - smtp # Sends notification e-mail messages via SMTP. # - from: required message source address. # - to: required message destination address. # - subject: required message subject. # - server: optional SMTP server. Defaults to 'localhost'. # - port: optional SMTP port. Defaults to port 25. # # - stdout # Displays notifications on standard out. Generally useful only for # debugging tf-notifier. # # Examples: # Send notifications via SMTP to a mobile phone. Batch notifications up # in 60 second intervals to conserve SMSes. # { # 'notify_after': 60, # 'when': { # 'backlogged': True, # }, # 'method': 'smtp', # 'from': 'user@example.com', # 'to': '0000000000@mms.att.com', # 'subject': 'TinyFugue notification', # }, # # Send notifications to a remote Mac using Growl. # { # 'method': 'growl', # 'appname': 'tf-notifier', # 'title': 'TinyFugue', # 'host': '192.168.0.1', # 'password': 'foobar', # }, # # Send notifications to a remote Mac using Prowl. # { # 'method': 'growl', # 'appname': 'tf-notifier', # 'title': 'TinyFugue', # 'apikey': '1234567890123456789012345678901234567890', # }, # # Send notifications to Gnome via libnotify. # { # 'method': 'libnotify', # 'title': 'TinyFugue', # 'urgency': 'low', # }, # # Print notifications to standard out, for debugging or trouleshooting. # { # 'method': 'stdout', # }, # # Institute quiet hours: only send notifications between the hours # of 09:00 and 22:00. # { # 'when': { # 'after': datetime.time(hour=9, minute=0, second=0), # 'before': datetime.time(hour=22, minute=0, second=0), # }, # 'method': 'smtp', # 'from': 'user@example.com', # 'to': '0000000000@mms.att.com', # 'subject': 'TinyFugue notification', # }, # NOTIFICATION = [ ] import datetime from email.Message import Message from getopt import getopt, GetoptError import os from os.path import basename import re from smtplib import SMTP from stat import * import subprocess import sys from syslog import * from time import sleep, time def usage(): print """Usage: %s [-c|--config FILE] [-m|--macro NAME] [LOGFILE] Watch a TinyFugue log file and send notifications when highlighted text is received. -c, --config path to .tfrc (~/.tfrc by default) -h, --help display this help and exit -m, --macro=NAME only pay attention to the specified macros""" % \ basename(sys.argv[0]) try: opts, args = getopt(sys.argv[1:], 'hm:', ['help', 'macro=']) except GetoptError, e: print >>sys.stderr, str(e) usage() sys.exit(2) TFRC = '~/.tfrc' MACROS = [] for opt in opts: if opt[0] == '-c' or opt[0] == '--config': TFRC = opt[1] elif opt[0] == '-h' or opt[0] == '--help': usage() sys.exit(0) elif opt[0] == '-m' or opt[0] == '--macro': MACROS.append(opt[1]) def parse_arg_string(argstr): """Parse a string containing arguments into an array of arguments as they would appear in (for example) sys.argv.""" # FIXME: would be nice if this supported backslash escaping # of quotes. in_quoted = False begin_index = 0 args = [] for i in range(len(argstr)): char = argstr[i] if not in_quoted and (char == ' ' or char == '\t'): args.append(argstr[begin_index:i]) begin_index = i + 1 elif char == '"' or char == "'": in_quoted = in_quoted == False args.append(argstr[begin_index:i + 1]) return args LOG_NAME = None PATTERNS = [] try: tfrc = open(os.path.expanduser(TFRC)) for line in tfrc: line = line.strip() if re.search(r'^/log\s', line): LOG_NAME = re.sub(r'^/log\s+', '', line) elif re.search(r'^/def\s', line): pattern = None is_highlighted = False # getopt() expects a list of arguments instead of an argument # string, so we'll have to split it ourselves. try: tf_opts, tf_args = getopt( parse_arg_string(re.sub(r'^/def\s', '', line)), 'm:n:E:t:h:b:B:p:c:w:T:Fa:P:fiIq1') except GetoptError, e: # We'd like to be resilient in the face of unknown # arguments (say, if newer versions of tf(1) add # new arguments to /def), but it appears getopt() # doesn't provide a way to do that. print >>sys.stderr, 'Unable to parse /def in %s: %s' % (TFRC, str(e)) sys.exit(1) for opt in tf_opts: if opt[0] == '-P': is_highlighted = True elif opt[0] == '-t': # Dispel quoting if present. if opt[1][0] == "'" and opt[1][-1] == "'": pattern = opt[1][1:-1] elif opt[1][0] == '"' and opt[1][-1] == '"': pattern = opt[1][1:-1] else: pattern = opt[1] if len(MACROS) and len(tf_args) >= 1: if tf_args[0] not in MACROS: continue if pattern and is_highlighted: PATTERNS.append(re.compile(pattern)) tfrc.close() except Exception, e: print >>sys.stderr, 'Unable to process .tfrc: %s' % str(e) sys.exit(1) if len(args) > 1: print >>sys.stderr, 'Only one TinyFugue log file may be specified.' usage() sys.exit(1) elif len(args) == 1: LOG_NAME = args[0] elif len(args) == 0: # Flail randomly at a log file name if the .tfrc didn't have one. if not LOG_NAME: LOG_NAME = '.tf.log' try: file = open(LOG_NAME, 'r') log_stat = os.stat(LOG_NAME) log_inode = log_stat[ST_INO] # Start looking at the end of the file. file.seek(log_stat[ST_SIZE]) except OSError, e: print '%s: %s' % (LOG_NAME, str(e)) sys.exit(1) if os.path.exists(os.path.expanduser('~/.tf-notifierrc')): try: fp = open(os.path.expanduser('~/.tf-notifierrc')) exec(''.join(fp.readlines())) fp.close() except Exception, e: print >>sys.stderr, 'Unable to load configuration file %s: %s' % \ (os.path.expanduser('~/.tf-notifierrc'), str(e)) sys.exit(1) queued = [] for n in NOTIFICATION: n['lines'] = [] n['last_sent'] = time() when = n.get('when', {}) for condition in when: try: cond_value = when[condition] except TypeError: if condition in ['after', 'before']: print >>sys.stderr, \ '%s: "%s" values must have a value to compare against.' % \ (condition, basename(sys.argv[0])) sys.exit(1) if condition in ['after', 'before']: if type(cond_value) not in [datetime.datetime, datetime.time]: print >>sys.stderr, \ '%s: "%s" values must be datetime.datetime or datetime.time objects.' % \ (condition, basename(sys.argv[0])) sys.exit(1) if n['method'] == 'smtp': if 'from' not in n: print >>sys.stderr, '%s: "from" is a required configuration item for the smtp method.' % basename(sys.argv[0]) sys.exit(1) if 'to' not in n: print >>sys.stderr, '%s: "to" is a required configuration item for the smtp method.' % basename(sys.argv[0]) sys.exit(1) if 'subject' not in n: print >>sys.stderr, '%s: "subject" is a required configuration item for the smtp method.' % basename(sys.argv[0]) sys.exit(1) try: n['msg'] = Message() n['msg']['From'] = n['from'] n['msg']['To'] = n['to'] n['msg']['Subject'] = n['subject'] except Exception, e: print >>sys.stderr, 'Unable to initialize smtp: %s' % str(e) sys.exit(1) elif n['method'] == 'growl': if 'title' not in n: print >>sys.stderr, '%s: "title" is a required configuration item for the growl method.' % basename(sys.argv[0]) sys.exit(1) if 'host' not in n: print >>sys.stderr, '%s: "host" is a required configuration item for the growl method.' % basename(sys.argv[0]) sys.exit(1) if 'password' not in n: print >>sys.stderr, '%s: "password" is a required configuration item for the growl method.' % basename(sys.argv[0]) sys.exit(1) try: from netgrowl import GROWL_UDP_PORT, GrowlRegistrationPacket, \ GrowlNotificationPacket from socket import socket, AF_INET, SOCK_DGRAM p = GrowlRegistrationPacket(application=n['appname'], password=n['password']) p.addNotification() addr = (n['host'], GROWL_UDP_PORT) n['sock'] = socket(AF_INET, SOCK_DGRAM) n['sock'].sendto(p.payload(), addr) except Exception, e: print >>sys.stderr, 'Unable to initialize growl: %s' % str(e) sys.exit(1) elif n['method'] == 'libnotify': if 'title' not in n: print >>sys.stderr, '%s: "title" is a required configuration item for the libnotify method.' % basename(sys.argv[0]) sys.exit(1) try: import pynotify if not pynotify.is_initted(): pynotify.init(n['appname']) except Exception, e: print >>sys.stderr, 'Unable to initialize libnotify: %s' % str(e) sys.exit(1) elif n['method'] == 'prowl': if 'apikey' not in n: print >>sys.stderr, '%s: "apikey" is a required configuration item for the prowl method.' % basename(sys.argv[0]) sys.exit(1) if 'title' not in n: print >>sys.stderr, '%s: "title" is a required configuration item for the prowl method.' % basename(sys.argv[0]) sys.exit(1) try: from prowlpy import Prowl n['prowl'] = Prowl(n['apikey']) try: n['prowl'].verify_key() except Exception, e: print >>sys.stderr, 'Unable to verify Prowl API key: %s' % str(e) sys.exit(1) except Exception, e: print >>sys.stderr, 'Unable to initialize prowl: %s' % str(e) sys.exit(1) openlog(basename(sys.argv[0]), LOG_PID) """Determine whether a notification condition matches.""" def condition_matches(condition, cond_value): if condition == 'after': if type(cond_value) == datetime.time(): now_value = datetime.datetime.now().time() elif type(cond_value) == datetime.datetime(): now_value = datetime.datetime.now() now_value.microsecond = 0 else: # Should never reach here since this should have # been checked for at startup. syslog(LOG_ERR, 'Invalid value for after condition: %s' % cond_value) if cond_value > now_value: return True return False if condition == 'backlogged': # If the log file is sticky, tf's screen is current (i.e., no More # indicator and therefore not in backscroll). global LOG_NAME sticky = (os.stat(LOG_NAME)[ST_MODE] & S_ISVTX) == S_ISVTX return cond_value == (not sticky) if condition == 'before': if type(cond_value) == datetime.time(): now_value = datetime.datetime.now().time() elif type(cond_value) == datetime.datetime(): now_value = datetime.datetime.now() now_value.microsecond = 0 else: # Should never reach here since this should have # been checked for at startup. syslog(LOG_ERR, 'Invalid value for after condition: %s' % cond_value) if cond_value < now_value: return True return False if condition == 'detached': is_detached = (not is_screen_attached(file=LOG_NAME)) return cond_value == is_detached return False """Obtain process information from lsof(1).""" def run_lsof(args): try: lsof = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = lsof.communicate()[0] if lsof.returncode < 0: syslog(LOG_ERR, 'lsof(1) exited with signal %d.' % -lsof.returncode) return None elif lsof.returncode != 0: syslog(LOG_ERR, 'lsof(1) exited with status %d.' % lsof.returncode) return None except OSError, e: syslog(LOG_ERR, 'lsof(1) failed: %s.' % str(e)) return None info = [] headers = [] for line in stdout.split('\n'): if not line: continue fields = re.split(r'\s+', line) if fields[0] == 'COMMAND': headers = fields continue if not headers: continue line = {} for i in range(min(len(headers), len(fields))): line[headers[i]] = fields[i] info.append(line) return info """Determine whether the screen: - with the given process ID - containing process(es) holding the given file open is attached. """ def is_screen_attached(file=None, pid=None): if file and pid: raise Exception('is_screen_attached(): file and pid arguments may not be used at the same time.') if file: info = run_lsof(['lsof', '-nR', file]) elif pid: info = run_lsof(['lsof', '-nRp', str(pid)]) else: return None if not info: return None procs = {} for proc in info: if 'PID' not in proc or 'PPID' not in proc: continue # Filter out anything that doesn't look like a PID. if not re.search(r'^\d+$', proc['PID']) or \ not re.search(r'^\d+$', proc['PPID']): continue procs[int(proc['PID'])] = int(proc['PPID']) our_pid = os.getpid() for pid, ppid in procs.iteritems(): # We'll have the log open; ignore ourselves. if pid == our_pid: continue try: screen = subprocess.Popen( ['screen', '-list', '%s.' % ppid], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = screen.communicate()[0] if screen.returncode < 0: syslog(LOG_ERR, 'Unable to determine whether screen at pid %d is attached; screen(1) exited with signal %d.' % ( pid, -screen.returncode)) return None elif screen.returncode == 1: # -list/-ls always exit with status 1, regardless of # whether the given argument matched a socket. # https://savannah.gnu.org/bugs/?26750 pass elif screen.returncode > 1: syslog(LOG_ERR, 'Unable to determine whether screen at pid %d is attached; screen(1) exited with status %d.' % ( pid, screen.returncode)) return None except OSError, e: syslog(LOG_ERR, 'Unable to determine whether screen at pid %d is attached: %s' % ( pid, str(e))) return None if 'detached' in stdout.lower(): return False elif 'attached' in stdout.lower(): return True else: # No screen found for this PID, try its parent unless we've # bottomed out and hit init(8). if ppid == 0: continue result = is_screen_attached(pid=ppid) if result != None: return result return None """Send any pending notifications.""" def perform_notifications(): global NOTIFICATION for n in NOTIFICATION: if not n['lines']: continue if n['last_sent'] + n.get('notify_after', 0) > time(): continue # FIXME: rate limiting support would be nice. if n['method'] == 'smtp': try: smtp = SMTP(n.get('server', 'localhost'), n.get('port', 25)) smtp.sendmail(n['from'], n['to'], str(n['msg']) + '\n'.join(n['lines'])) smtp.quit() n['last_sent'] = time() n['lines'] = [] except Exception, e: syslog(LOG_ERR, 'Unable to send SMTP notification (%d lines): %s' % (len(n['lines']), str(e))) elif n['method'] == 'growl': try: p = GrowlNotificationPacket( password=n['password'], application=n.get('appname', basename(sys.argv[0])), title=n['title'], description='\n'.join(n['lines'])) addr = (n['host'], GROWL_UDP_PORT) n['sock'].sendto(p.payload(), addr) n['last_sent'] = time() n['lines'] = [] except Exception, e: syslog(LOG_ERR, 'Unable to send growl notification (%d lines): %s' % (len(n['lines']), str(e))) elif n['method'] == 'libnotify': try: l = pynotify.Notification(n['title'], '\n'.join(n['lines'])) l.set_urgency( {'low': pynotify.URGENCY_LOW, 'normal': pynotify.URGENCY_NORMAL, 'urgent': pynotify.URGENCY_URGENT, }[n.get('urgency', 'low')] ) l.show() n['last_sent'] = time() n['lines'] = [] except Exception, e: syslog(LOG_ERR, 'Unable to send libnotify notification (%d lines): %s' % (len(n['lines']), str(e))) elif n['method'] == 'prowl': try: n['prowl'].add(n['appname'], n['title'], '\n'.join(n['lines'])) n['last_sent'] = time() n['lines'] = [] except Exception, e: syslog(LOG_ERR, 'Unable to send prowl notification (%d lines): %s' % (len(n['lines']), str(e))) elif n['method'] == 'stdout': print '\n'.join(n['lines']) n['last_sent'] = time() n['lines'] = [] while True: # Read all new log lines. while True: # If the log's inode has changed, it's probably been rotated. # Start following the new log from the beginning. try: log_stat = os.stat(LOG_NAME) if log_stat[ST_INO] != log_inode: log_inode = log_stat[ST_INO] file = open(LOG_NAME, 'r') except OSError, e: syslog(LOG_ERR, 'Error while checking for %s rotation, retrying: %s' % (LOG_NAME, str(e))) try: sleep(5) except KeyboardInterrupt: closelog() sys.exit(0) continue line = file.readline().rstrip() if not line: # We've exhausted all the new log lines. Send # notifications (if any are pending) and wait # a bit before fetching new log lines. perform_notifications() try: sleep(2) except KeyboardInterrupt: closelog() sys.exit(0) continue seen_instances = [] for pattern in PATTERNS: if not pattern.search(line): continue for n in NOTIFICATION: # Empirically, casting a dict to a string produces the same # result regardless of the order in which items are added to # the dict, but it seems dangerous to rely on that. # # Instead, generate a sorted list (therefore guaranteed # order) of the non-internal elements of this notification # instance to determine whether we've already sent this line # to another notification instance with identical settings. notify_instance = sorted([ '%s: %s' % (k, v) for k, v in n.iteritems() if k not in ['notify_after', 'when', 'lines', 'last_sent'] ]) if notify_instance in seen_instances: continue when = n.get('when', {}) if not when: n['lines'].append(line) seen_instances.append(notify_instance) continue num_satisfied = 0 for condition in when: try: cond_value = when[condition] except TypeError: cond_value = True except Exception, e: syslog(LOG_ERR, 'Unexpected exception when retrieving value for condition %s: %s' % (condition, str(e))) continue if condition_matches(condition, cond_value): num_satisfied += 1 if num_satisfied == len(when): n['lines'].append(line) seen_instances.append(notify_instance) # This line has already matched a pattern we're paying # attention to (PATTERNS). Don't proceed to check other # patterns, since we'll send duplicate notifications for # this line. break closelog()