Skip to content
Snippets Groups Projects
irker.py 8.91 KiB
Newer Older
Eric S. Raymond's avatar
Eric S. Raymond committed
#!/usr/bin/env python
"""
irker - a simple IRC multiplexer daemon

Takes JSON objects of the form {'to':<channel-url>, 'privmsg':<text>}
and relays messages to IRC channels.  The channel value can be a list of
channels as well.
Eric S. Raymond's avatar
Eric S. Raymond committed
Run this as a daemon in order to maintain stateful connections to IRC
Eric S. Raymond's avatar
Eric S. Raymond committed
servers; this will allow it to respond to server pings and minimize
join/leave traffic.
Eric S. Raymond's avatar
Eric S. Raymond committed

Requires Python 2.6 and the irc.client library: see.

http://sourceforge.net/projects/python-irclib
TO-DO: Register the port?
Eric S. Raymond's avatar
Eric S. Raymond committed
"""
# These things might need tuning

HOST = "localhost"
PORT = 4747

NAMESTYLE = "irker%03d"		# IRC nick template - must contain '%d'
Eric S. Raymond's avatar
Eric S. Raymond committed
XMIT_TTL = (3 * 60 * 60)	# Time to live, seconds from last transmit
PING_TTL = (15 * 60)		# Time to live, seconds from last PING
Eric S. Raymond's avatar
Eric S. Raymond committed
CONNECT_MAX = 18		# Maximum connections per bot (freenet limit)

# No user-serviceable parts below this line

Eric S. Raymond's avatar
Eric S. Raymond committed
import sys, json, exceptions, getopt, urlparse, time, socket
Eric S. Raymond's avatar
Eric S. Raymond committed
import threading, Queue, SocketServer
import irc.client, logging
Eric S. Raymond's avatar
Eric S. Raymond committed

class SessionException(exceptions.Exception):
    def __init__(self, message):
        exceptions.Exception.__init__(self)
        self.message = message

class Session():
    "IRC session and message queue processing."
Eric S. Raymond's avatar
Eric S. Raymond committed
    def __init__(self, irker, url):
        self.irker = irker
        self.url = url
        self.last_xmit = time.time()
        self.last_ping = time.time()       
        # Server connection setup
        parsed = urlparse.urlparse(url)
Eric S. Raymond's avatar
Eric S. Raymond committed
        host, _, port = parsed.netloc.partition(':')
        if not port:
            port = 6667
        self.servername = host
        self.channel = parsed.path.lstrip('/')
        self.port = int(port)
        # The consumer thread
        self.queue = Queue.Queue()
        self.thread = threading.Thread(target=self.dequeue)
        self.thread.daemon = True
        self.thread.start()
    def enqueue(self, message):
        "Enque a message for transmission."
        self.queue.put(message)
    def dequeue(self):
        "Try to ship pending messages from the queue."
        while True:
            # We want to be kind to the IRC servers and not hold unused
            # sockets open forever, so they have a time-to-live.  The
            # loop is coded this particular way so that we can drop
            # the actual server connection when its time-to-live
            # expires, then reconnect and resume transmission if the
            # queue fills up again.
            if not self.server:
                self.server = self.irker.open(self.servername,
                                                         self.port)
Eric S. Raymond's avatar
Eric S. Raymond committed
                self.irker.debug(1, "XMIT_TTL bump (connection) at %s" % time.asctime())
                self.last_xmit = time.time()
            elif self.queue.empty():
                now = time.time()
                if now > self.last_xmit + XMIT_TTL \
                       or now > self.last_ping + PING_TTL:
                    self.irker.debug(1, "timing out inactive connection at %s" % time.asctime())
                    self.irker.close(self.servername,
                                                 self.port)
                    self.server = None
                    break
            else:
                message = self.queue.get()
                self.server.join("#" + self.channel)
                self.server.privmsg("#" + self.channel, message)
Eric S. Raymond's avatar
Eric S. Raymond committed
                self.last_xmit = time.time()
                self.irker.debug(1, "XMIT_TTL bump (transmission) at %s" % time.asctime())
    def terminate(self):
        "Terminate this session"
        self.server.quit("#" + self.channel)
        self.server.close()
    def await(self):
Eric S. Raymond's avatar
Eric S. Raymond committed
        "Block until processing of all queued messages is done."
        self.queue.join()
Eric S. Raymond's avatar
Eric S. Raymond committed
class Irker:
    "Persistent IRC multiplexer."
    def __init__(self, debuglevel=0, namesuffix=None):
        self.debuglevel = debuglevel
        self.namesuffix = namesuffix or socket.getfqdn().replace(".", "-")
        self.irc = irc.client.IRC()
        self.irc.add_global_handler("ping", lambda c, e: self._handle_ping(c,e))
Eric S. Raymond's avatar
Eric S. Raymond committed
        thread = threading.Thread(target=self.irc.process_forever)
        self.irc._thread = thread
        thread.daemon = True
        thread.start()
        self.sessions = {}
        self.countmap = {}
        self.servercount = 0
Eric S. Raymond's avatar
Eric S. Raymond committed
    def logerr(self, errmsg):
        "Log a processing error."
        sys.stderr.write("irker: " + errmsg + "\n")
Eric S. Raymond's avatar
Eric S. Raymond committed
    def debug(self, level, errmsg):
        "Debugging information."
        if self.debuglevel >= level:
            sys.stderr.write("irker[%d]: %s\n" % (self.debuglevel, errmsg))
Eric S. Raymond's avatar
Eric S. Raymond committed
    def nickname(self, n):
        "Return a name for the nth server connection."
        # The purpose of including the namme suffix (defaulting to the
        # host's FQDN) is to ensure that the nicks of bots managed by
        # instances running on different hosts can never collide.
        return (NAMESTYLE % n) + "-" + self.namesuffix
    def open(self, servername, port):
        "Allocate a new server instance."
        if not (servername, port) in self.countmap:
            self.countmap[(servername, port)] = (CONNECT_MAX+1, None)
        count = self.countmap[(servername, port)][0]
        if count > CONNECT_MAX:
            self.servercount += 1
            newserver = self.irc.server()
            newserver.connect(servername,
                              port,
Eric S. Raymond's avatar
Eric S. Raymond committed
                              self.nickname(self.servercount))
            self.countmap[(servername, port)] = (1, newserver)
            self.debug(1, "new server connection %d opened for %s:%s" % \
                       (self.servercount, servername, port))
        else:
            self.debug(1, "reusing server connection for %s:%s" % \
                       (servername, port))
        return self.countmap[(servername, port)][1]
    def close(self, servername, port):
        "Release a server instance and all sessions using it."
        del self.countmap[(servername, port)]
Eric S. Raymond's avatar
Eric S. Raymond committed
        for val in self.sessions.values():
            if (val.servername, val.port) == (servername, port):
                self.sessions[servername].terminate()
                del self.sessions[servername]
    def _handle_ping(self, connection, event):
        "PING arrived, bump the last-received time for the connection."
        for (name, server) in self.sessions.items():
            if name == connection.server:
                server.last_ping = time.time()
Eric S. Raymond's avatar
Eric S. Raymond committed
    def handle(self, line):
        "Perform a JSON relay request."
        try:
            request = json.loads(line.strip())
            if "to" not in request or "privmsg" not in request:
                self.logerr("malformed reqest - 'to' or 'privmsg' missing: %s" % repr(request))
Eric S. Raymond's avatar
Eric S. Raymond committed
            else:
                channels = request['to']
                message = request['privmsg']
                if type(channels) not in (type([]), type(u"")) \
                       or type(message) != type(u""):
                    self.logerr("malformed request - unexpected types: %s" % repr(request))
                else:
                    if type(channels) == type(u""):
                        channels = [channels]
                    for channel in channels:
                        if type(channel) != type(u""):
                            self.logerr("malformed request - unexpected type: %s" % repr(request))
                        else:
                            if channel not in self.sessions:
                                self.sessions[channel] = Session(self, channel)
                            self.sessions[channel].enqueue(message)
Eric S. Raymond's avatar
Eric S. Raymond committed
        except ValueError:
            self.logerr("can't recognize JSON on input: %s" % repr(line))
Eric S. Raymond's avatar
Eric S. Raymond committed
    def terminate(self):
        "Ship all pending messages before terminating."
        for session in self.sessions.values():
            session.await()

class IrkerTCPHandler(SocketServer.StreamRequestHandler):
Eric S. Raymond's avatar
Eric S. Raymond committed
    def handle(self):
Eric S. Raymond's avatar
Eric S. Raymond committed
        while True:
            irker.handle(self.rfile.readline().strip())
class IrkerUDPHandler(SocketServer.BaseRequestHandler):
    def handle(self):
        data = self.request[0].strip()
        #socket = self.request[1]
        irker.handle(data)

Eric S. Raymond's avatar
Eric S. Raymond committed
if __name__ == '__main__':
Eric S. Raymond's avatar
Eric S. Raymond committed
    debuglevel = 0
    tcp = False
    (options, arguments) = getopt.getopt(sys.argv[1:], "d:p:n:t")
Eric S. Raymond's avatar
Eric S. Raymond committed
    for (opt, val) in options:
        if opt == '-d':
Eric S. Raymond's avatar
Eric S. Raymond committed
            debuglevel = int(val)
            if debuglevel > 1:
                logging.basicConfig(level=DEBUG)
Eric S. Raymond's avatar
Eric S. Raymond committed
        elif opt == '-p':
            port = int(val)
        elif opt == '-n':
            namesuffix = val
        elif opt == '-t':
            tcp = True
    irker = Irker(debuglevel=debuglevel, namesuffix=namesuffix)
    if tcp:
        server = SocketServer.TCPServer((host, port), IrkerTCPHandler)
    else:
        server = SocketServer.UDPServer((host, port), IrkerUDPHandler)
    except KeyboardInterrupt: