328 lines
12 KiB
Python
Executable File
328 lines
12 KiB
Python
Executable File
#!/usr/bin/python3
|
|
"""
|
|
WeeNotify
|
|
|
|
A minimalist Weechat client using the Weechat relay protocol to
|
|
retrieve notifications from a bouncer and display them locally.
|
|
|
|
---
|
|
This program 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 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 General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import threading
|
|
|
|
import packetRead
|
|
|
|
##################### CONFIGURATION ##########################
|
|
DEFAULT_CONF=(os.path.expanduser("~"))+'/.weenotifyrc'
|
|
##################### END CONFIGURATION ######################
|
|
|
|
def expandPaths(path):
|
|
return os.path.expanduser(path)
|
|
|
|
def safeCall(callArray):
|
|
if(len(callArray) == 0):
|
|
logging.error("Trying to call an unspecified external program.")
|
|
return
|
|
try:
|
|
subprocess.call(shlex.split(callArray[0])+callArray[1:])
|
|
except:
|
|
logging.error("Could not execute "+callArray[0])
|
|
|
|
class RelayClient(threading.Thread):
|
|
def __init__(self, conf):
|
|
threading.Thread.__init__(self)
|
|
self.daemon = True # Stop when the program terminates
|
|
self.conf = conf
|
|
self.sock = None
|
|
self.packet_actions = {
|
|
'ask_buffers' : self.asked_buffers,
|
|
'_buffer_line_added' : self.buffer_line_added
|
|
}
|
|
self.buffers = {}
|
|
|
|
def run(self):
|
|
self.connect()
|
|
while True:
|
|
READ_AT_ONCE = 4096
|
|
data = self.recv(READ_AT_ONCE)
|
|
if len(data) < 5:
|
|
logging.warning("Packet shorter than 5 bytes received. Ignoring.")
|
|
continue
|
|
|
|
dataLen, _ = packetRead.read_int(data)
|
|
lastPacket = data
|
|
while len(data) < dataLen:
|
|
if len(lastPacket) < READ_AT_ONCE:
|
|
logging.warning("Incomplete packet received. Ignoring.")
|
|
break
|
|
lastPacket = self.recv(READ_AT_ONCE)
|
|
data += lastPacket
|
|
if len(data) < dataLen:
|
|
continue
|
|
self.process_packet(data)
|
|
|
|
def process_packet(self, packet):
|
|
if packet[4] != 0:
|
|
logging.warning("Received compressed message. Ignoring.")
|
|
return
|
|
body = packet[5:]
|
|
ident, body = packetRead.read_str(body)
|
|
if ident in self.packet_actions:
|
|
self.packet_actions[ident](body)
|
|
|
|
def connect(self):
|
|
while True:
|
|
try:
|
|
self.sock = socket.socket()
|
|
logging.info("Connecting to " + self.conf['server'] + ":" + self.conf['port'] + "...")
|
|
self.sock.connect((self.conf['server'], int(self.conf['port'])))
|
|
logging.info("Connected")
|
|
self.init_connection()
|
|
return
|
|
except ConnectionRefusedError:
|
|
self.sock = None
|
|
logging.error("Connection refused. Retrying...")
|
|
except socket.error as exn:
|
|
self.sock = None
|
|
logging.error("Connection error: %s. Retrying..." % exn)
|
|
time.sleep(float(self.conf['reconnect-delay']))
|
|
|
|
def init_connection(self):
|
|
password = self.conf.get('password', None)
|
|
if password != None:
|
|
self.sock.sendall(b'init compression=off,password='+password.encode("utf-8")+b'\n')
|
|
else:
|
|
self.sock.sendall(b'init compression=off\n')
|
|
self.sock.sendall(b'sync *\n')
|
|
# Ask for name of buffers
|
|
self.sock.sendall(b'(ask_buffers) hdata buffer:gui_buffers(*) name\n')
|
|
|
|
def recv(self, n):
|
|
while True:
|
|
try:
|
|
data = self.sock.recv(n)
|
|
if data:
|
|
return data
|
|
logging.warning("Connection lost. Retrying...")
|
|
except socked.error as exn:
|
|
logging.error("Connection error: %s. Retrying..." % exn)
|
|
self.connect()
|
|
|
|
def asked_buffers(self, body):
|
|
data_type, body = packetRead.read_typ(body)
|
|
if(data_type != "hda"):
|
|
logging.warning("Unknown asked_buffers format. Ignoring.")
|
|
return
|
|
hdaData, _ = packetRead.read_hda(body)
|
|
for hda in hdaData:
|
|
self.buffers[hda['__path'][-1]] = hda['name']
|
|
|
|
def buffer_line_added(self, body):
|
|
data_type, body = packetRead.read_typ(body)
|
|
if(data_type != "hda"):
|
|
logging.warning("Unknown buffer_line_added format. Ignoring.")
|
|
return
|
|
hdaData, _ = packetRead.read_hda(body)
|
|
for hda in hdaData:
|
|
msg = hda['message']
|
|
buffer = hda.get('buffer', 0)
|
|
if buffer not in self.buffers:
|
|
self.sock.sendall(b'(ask_buffers) hdata buffer:gui_buffers(*) name\n')
|
|
buffer_name = '<unknown>'
|
|
else:
|
|
buffer_name = self.buffers[buffer]
|
|
|
|
nick = ""
|
|
|
|
for tag in hda['tags_array']:
|
|
if tag.startswith('nick_'):
|
|
nick = tag[5:]
|
|
|
|
if hda['highlight'] > 0:
|
|
self.gotHighlight(msg, nick, buffer_name)
|
|
continue
|
|
|
|
for tag in hda['tags_array']:
|
|
if tag.startswith('notify_'):
|
|
notifLevel = tag[7:]
|
|
if notifLevel == 'private':
|
|
self.gotPrivMsg(msg, nick, buffer_name)
|
|
break
|
|
|
|
def gotHighlight(self, message, nick, buffer_name):
|
|
if not selt.conf.get('highlight-action', None):
|
|
return # No action defined: do nothing.
|
|
|
|
logging.debug("Notifying highlight message.")
|
|
highlightProcessCmd = expandPaths(self.conf['highlight-action'])
|
|
safeCall([highlightProcessCmd, message, nick, buffer_name])
|
|
|
|
def gotPrivMsg(self, message, nick, buffer_name):
|
|
if not self.conf.get('privmsg-action', None):
|
|
return # No action defined: do nothing.
|
|
|
|
logging.debug("Notifying private message.")
|
|
privmsgProcessCmd = expandPaths(self.conf['privmsg-action'])
|
|
safeCall([privmsgProcessCmd, message, nick, buffer_name])
|
|
|
|
|
|
CONFIG_ITEMS = [
|
|
('-c','config', 'Use the given configuration file.', DEFAULT_CONF),
|
|
('-s','server', 'Address of the Weechat relay.'),
|
|
('-p','port', 'Port of the Weechat relay.'),
|
|
('','ensure-background', 'Runs the following command in the background.'+\
|
|
' Periodically checks whether it is still open, reruns it if '+\
|
|
'necessary, and resets the connection to the server if it was lost '+\
|
|
'in the process. Mostly useful to establish a SSH tunnel.'),
|
|
('','reconnect-delay','Delay between two attempts to reconnect after '+\
|
|
'being disconnected from the server.', '10'),
|
|
('-a','highlight-action', 'Program to invoke when highlighted.'),
|
|
('','privmsg-action', 'Program to invoke when receiving a private message.'),
|
|
('','log-file', 'Log file. If omitted, the logs will be directly printed.'),
|
|
('','password', 'Relay password')
|
|
]
|
|
|
|
def readConfig(path, createIfAbsent=False):
|
|
outDict = dict()
|
|
try:
|
|
with open(path,'r') as handle:
|
|
confOpts = [ x[1] for x in CONFIG_ITEMS ]
|
|
for line in handle:
|
|
if '#' in line:
|
|
line = line[:line.index('#')].strip()
|
|
if(line == ''):
|
|
continue
|
|
|
|
if '=' in line:
|
|
eqPos = line.index('=')
|
|
attr = line[:eqPos].strip()
|
|
arg = line[eqPos+1:].strip()
|
|
if(attr in confOpts): # Valid option
|
|
outDict[attr] = arg
|
|
else:
|
|
logging.warning('Unknown option: '+attr+'.')
|
|
handle.close()
|
|
except FileNotFoundError:
|
|
if(createIfAbsent):
|
|
with open(path, 'x') as touchHandle:
|
|
pass
|
|
else:
|
|
logging.error("The configuration file '"+path+"' does not exists.")
|
|
except IOError:
|
|
logging.error("Could not read the configuration file at '"+path+"'.")
|
|
return outDict
|
|
|
|
def readCommandLine():
|
|
parser = argparse.ArgumentParser(description="WeeChat client to get "+\
|
|
"highlight notifications from a distant bouncer.")
|
|
parser.add_argument('-v', action='store_true')
|
|
for cfgItem in CONFIG_ITEMS:
|
|
shortOpt,longOpt,helpMsg,dft = cfgItem[0],cfgItem[1],cfgItem[2],None
|
|
if len(cfgItem) >= 4:
|
|
dft = cfgItem[3]
|
|
if shortOpt == '':
|
|
parser.add_argument('--'+longOpt, dest=longOpt, help=helpMsg,\
|
|
default=dft)
|
|
else:
|
|
parser.add_argument(shortOpt, '--'+longOpt, dest=longOpt,\
|
|
help=helpMsg, default=dft)
|
|
parsed = parser.parse_args()
|
|
|
|
parsedTable = vars(parsed)
|
|
|
|
return parsedTable
|
|
|
|
def dictUnion(d1,d2):
|
|
out = d1
|
|
for key in d2.keys():
|
|
if not(d2[key] == None and key in d1):
|
|
out[key] = d2[key]
|
|
return out
|
|
|
|
def ensureBackgroundCheckRun(proc,conf):
|
|
""" Runs (or re-runs if it has terminated) the 'ensure-background'
|
|
option command-line if it was specified. """
|
|
if not 'ensure-background' in conf or not conf['ensure-background']:
|
|
return
|
|
|
|
if proc == None or proc.poll() != None: # Not started or terminated
|
|
if proc != None: # Proc has died.
|
|
logging.warning("Background process has died.")
|
|
logging.info("Starting background process...")
|
|
proc = subprocess.Popen(shlex.split(conf['ensure-background']))
|
|
time.sleep(0.5) # Wait a little to let it settle.
|
|
return proc
|
|
|
|
def main():
|
|
def sigint(sig, frame):
|
|
if(bgProcess != None):
|
|
bgProcess.terminate()
|
|
logging.info("Terminated background process.")
|
|
logging.info("Stopped.")
|
|
exit(0)
|
|
|
|
conf = readCommandLine()
|
|
conf = dictUnion(readConfig(conf['config'],True), conf)
|
|
# command line prevails
|
|
|
|
if 'log-file' not in conf:
|
|
conf['log-file']=None
|
|
if conf['log-file'] != None:
|
|
conf['log-file'] = expandPaths(conf['log-file'])
|
|
if conf['log-file'] != None and not os.path.isfile(conf['log-file']):
|
|
try:
|
|
touchHandle = open(conf['log-file'],'x')
|
|
touchHandle.close()
|
|
except:
|
|
print("ERROR: failed to create log file. Exiting.")
|
|
exit(1)
|
|
|
|
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s',\
|
|
datefmt='%H:%M:%S', filename=conf['log-file'])
|
|
|
|
logging.getLogger().setLevel(logging.INFO)
|
|
if('v' in conf and conf['v']): # Verbose
|
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
logging.info("Verbose mode.")
|
|
|
|
if not 'server' in conf or not conf['server'] or\
|
|
not 'port' in conf or not conf['port']:
|
|
print("Missing argument(s): server address and/or port.")
|
|
exit(1)
|
|
|
|
signal.signal(signal.SIGINT, sigint)
|
|
signal.signal(signal.SIGTERM, sigint)
|
|
|
|
client = RelayClient(conf)
|
|
bgProcess = ensureBackgroundCheckRun(None, conf)
|
|
|
|
logging.info("Entering main loop.")
|
|
client.start()
|
|
while True:
|
|
bgProcess = ensureBackgroundCheckRun(bgProcess, conf)
|
|
time.sleep(0.5)
|
|
|
|
if __name__=='__main__':
|
|
main()
|