add initial code back under fixbot/, the git-convert somehow broke
authorterom@fixme.fi
Mon, 15 Sep 2008 00:27:05 +0300
changeset 21 aa6df8f9c44a
child 22 1f1a21852abc
add initial code back under fixbot/, the git-convert somehow broke
fixbot/__init__.py
fixbot/_utmp.c
fixbot/api.py
fixbot/buffer.py
fixbot/example.py
fixbot/fifo.py
fixbot/irc.py
fixbot/logwatch_filters.py
fixbot/logwatch_sources.py
fixbot/logwatcher.py
fixbot/nexus.py
fixbot/setup.py
fixbot/tap.py
fixbot/utmp.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/_utmp.c	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,57 @@
+#include <Python.h>
+
+#include <utmp.h>
+
+#include <stdio.h>
+
+PyObject *parse (PyObject *self, PyObject *args) {
+    char *bytes;
+    size_t size;
+
+    struct utmp *item;
+    
+    /* not unicode */
+    if (!PyArg_ParseTuple(args, "t#", &bytes, &size))
+        return NULL;
+
+    if (size != sizeof(struct utmp)) {
+        PyErr_SetString(PyExc_ValueError, "given buffer is of the wrong length");
+        return NULL;
+    }
+
+    item = (struct utmp *) bytes;
+    
+    /* parse utmp from bytes to result */
+    return Py_BuildValue("hIs#s#s#s#(hh)i(ii)s#",
+        item->ut_type, item->ut_pid,
+        item->ut_line, sizeof(item->ut_line),
+        item->ut_id, sizeof(item->ut_id),
+        item->ut_user, sizeof(item->ut_user),
+        item->ut_host, sizeof(item->ut_host),
+        item->ut_exit.e_termination, item->ut_exit.e_exit,
+        item->ut_session,
+        item->ut_tv.tv_sec, item->ut_tv.tv_usec,
+        item->ut_addr_v6, sizeof(item->ut_addr_v6)
+    );
+}
+
+PyObject *size (PyObject *self, PyObject *args) {
+    /* return the size of an UTMP struct */
+
+    if (!PyArg_ParseTuple(args, ""))
+        return NULL;
+
+    return PyInt_FromSsize_t(sizeof(struct utmp));
+}
+
+static PyMethodDef module_methods[] = {
+    {"parse",       parse,      METH_VARARGS,   "parse a utmp struct from a byte string"},
+    {"size",        size,       METH_VARARGS,   "return the size of an utmp record in bytes"},
+    {NULL}
+};
+
+PyMODINIT_FUNC init_utmp(void) {
+    PyObject *m;
+
+    m = Py_InitModule3("_utmp", module_methods, "utmp struct parsing");
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/api.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,212 @@
+from twisted.internet import protocol, reactor
+from twisted.python import log
+from datetime import datetime
+import sys
+
+import buffer
+
+from api_secret import secret
+
+class ModuleInfo (object) :
+    """
+        Some info about a module
+    """
+
+    # module's name
+    name = None
+
+    # module's version, as a 16-bit integer
+    version = None
+
+    # list of valid event types (strings)
+    event_types = None
+
+    def __str__ (self) :
+        return "Module %s:%d" % (self.name, self.version)
+    
+    def __repr__ (self) :
+        return "<module %s:%d with events: %s>" % (self.name, self.version, ", ".join(self.event_types))
+
+class Event (object) :
+    # the ModuleInfo object
+    module = None
+
+    # the event type as a string
+    type = None
+
+    # event message as a string (under 255 bytes in length!)
+    msg = None
+
+    # timestamp as a datetime.datetime
+    when = None
+
+    def __init__ (self, module, type, msg) :
+        assert type in module.event_types, "Invalid event-type %s for %r" % (type, self.module)
+        
+        self.module = module
+        self.type = type
+        self.msg = msg
+
+        self.when = datetime.now()
+    
+    def __str__ (self) :
+        return "[%s] %s" % (self.type, self.msg)
+    
+    def __repr__ (self) :
+        return "%s @ %s" % (self.type, self.when)
+ 
+CLIENT_COMMANDS = [
+    "module_init",
+    "module_event",
+]
+
+SERVER_COMMANDS = [
+    "module_ok",
+]
+       
+class ServerProtocol (buffer.StreamProtocol, protocol.Protocol) :
+    RECV_COMMANDS = CLIENT_COMMANDS
+    SEND_COMMANDS = SERVER_COMMANDS    
+
+    VALID_STATES = [
+        "wait_init",
+        "wait_event"
+    ]
+    
+    # proto state
+    state = None
+
+    # module info
+    module = None
+    
+    def _assert (self, condition, msg) :
+        if not condition :
+            self.transport.loseConnection()
+            log.err("assert failed in APIProtocol for %s: %s" % (self.module, msg))
+    
+    def connectionMade (self) :
+        log.msg("Client connected")
+
+    def connectionLost (self, reason) :
+        log.msg("Connection lost: %s" % reason)
+        
+        if self.module :
+            self.factory.nexus.unregisterModule(self.module, reason.getErrorMessage())
+
+    def on_module_init (self, i) :
+        self._assert(not self.module, "module_init with non-None self.module")
+
+        peer_secret = i.readVarLen('B')
+        
+        self._assert(peer_secret == secret, "Mismatching API secrets!")
+
+        m = ModuleInfo()
+        
+        m.name = i.readVarLen('B')
+        m.version = i.readItem('H')
+
+        m.event_types = list(buffer.readStringStream(i, 'B'))
+        m.addr = self.transport.getPeer()
+
+        self.module_name = m.name
+
+        log.msg("Got mod_init for %r" % m)
+        
+        self.factory.nexus.registerModule(m, self)
+
+        self.module = m
+
+        o = self.startCommand('module_ok')
+
+        self.send(o)
+
+    def on_module_event (self, i) :
+        self._assert(self.module, "module_event with None self.module!")
+
+        event_type = i.readEnum(self.module.event_types)
+        event_msg = i.readVarLen('B')
+        
+        e = Event(self.module, event_type, event_msg)
+
+#        log.msg("Got mod_event of %r" % (e))
+
+        self.factory.nexus.handleEvent(e)
+
+    def logPrefix (self) :
+        if self.module :
+            return str(self.module)
+        else :
+            return super(ServerProtocol, self).logPrefix()
+
+class ClientProtocol (buffer.StreamProtocol, protocol.Protocol) :
+    RECV_COMMANDS = SERVER_COMMANDS
+    SEND_COMMANDS = CLIENT_COMMANDS
+
+    def connectionMade (self) :
+        log.msg("Connected to API server, sending module init message")
+
+        o = self.startCommand('module_init')
+        o.writeVarLen('B', secret)
+        o.writeVarLen('B', self.factory.name)
+        o.writeItem("H", self.factory.version)
+        buffer.writeStringStream(o, 'B', self.factory.event_types)
+
+        self.send(o)
+
+    def sendEvent (self, event) :
+        o = self.startCommand('module_event')
+        o.writeEnum(self.factory.event_types, event.type)
+        o.writeVarLen('B', event.msg)
+
+        self.send(o)
+
+    def on_module_ok (self, i) :
+        log.msg("Registration OK")
+
+        self.factory.connected(self)
+    
+    def logPrefix (self) :
+        return "module %s:%d client" % (self.factory.name, self.factory.version)
+
+class Module (ModuleInfo, protocol.ClientFactory) :
+    protocol = ClientProtocol
+
+    def __init__ (self) :
+        log.msg("Connecting to %s:%d" % (SERVER_HOST, PORT))
+        reactor.connectTCP(SERVER_HOST, PORT, self)
+
+        self.connection = None
+
+    def run (self) :
+        log.startLogging(sys.stderr)
+    
+        reactor.run()
+        
+    def connected (self, connection) :
+        log.msg("Connected!")
+        self.connection = connection
+
+        self.handleConnect()
+
+    def disconnect (self) :
+        self.connection.transport.loseConnection()
+    
+    def sendEvent (self, type, msg) :
+        self.connection.sendEvent(self.buildEvent(type, msg))
+
+    def buildEvent (self, type, msg) :
+        return Event(self, type, msg)
+
+    def handleConnect (self) :
+        """
+            Do something
+        """
+
+        pass
+
+class ServerFactory (protocol.ServerFactory) :
+    protocol = ServerProtocol
+
+    def __init__ (self, nexus) :
+        self.nexus = nexus
+        
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/buffer.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,417 @@
+try:
+    from cStringIO import StringIO
+except ImportError:
+    from StringIO import StringIO
+import struct
+
+# prefixed to all struct format strings
+STRUCT_PREFIX = '!'
+
+def hex (bytes) :
+    return ' '.join(['%#04x' % ord(b) for b in bytes])
+    
+class NotEnoughDataError (Exception) : 
+    pass
+
+class IStreamBase (object) :
+    # prefixed to all struct format strings
+    STRUCT_PREFIX = '!'
+
+class IReadStream (IStreamBase) :
+    """
+        IReadStream simply provides various interfaces for reading bytes from a
+        stream in various ways
+    """
+
+    def read (self, size=None) :
+        """
+            Read and return up to the given amount of bytes, or all bytes
+            available if no size given.
+        """
+
+        abstract
+
+    def readStruct (self, fmt) :
+        """
+            Reads the correct amount of data and then unpacks it according to
+            the given format. Note that this always returns a tuple, for single
+            items, use readItem
+        """
+        
+        fmt = self.STRUCT_PREFIX + fmt
+
+        fmt_size = struct.calcsize(fmt)
+        data = self.read(fmt_size)
+        
+        return struct.unpack(fmt, data)
+
+    def readItem (self, fmt) :
+        """
+            Reads the correct amount of data, unpacks it according to the 
+            given format, and then returns the first item.
+        """
+
+        return self.readStruct(fmt)[0]
+
+    def readVarLen (self, len_type) :
+        """
+            Return the data part of a <length><data> structure.
+            len_type indicates what type length has (struct format code).
+
+            In the case of <length> being zero, returns an empty string.
+        """
+        
+        size = self.readItem(len_type)
+        
+        if size :
+            return self.read(size)
+        else :
+            return ""
+
+    def readEnum (self, enum) :
+        """
+            Returns the item from the given list of enum values that corresponds
+            to a single-byte value read from the stream
+        """
+
+        return enum[self.readItem('B')]
+
+class ISeekableStream (IStreamBase) :
+    """
+        Extends IStreamBase to provide the ability to seek backwards into the
+        stream (which still does not know it's length, and thence cannot seek
+        forwards).
+    """
+
+    _position = None
+
+    def tell (self) :
+        """
+            Return the current offset into the seekable stream
+        """
+        
+        abstract
+
+    def seek (self, pos) :
+        """
+            Seek to the given position in the stream. 
+        """
+
+        abstract
+
+    def mark (self) :
+        """
+            Set a mark that can be later rolled back to with .reset()
+        """
+        
+        self._position = self.tell()
+
+    def unmark (self) :
+        """
+            Remove the mark without affecting the current position
+        """
+        
+        self._position = None
+    
+    def reset (self) :
+        """
+            Rolls the buffer back to the position set earlier with mark()
+        """
+        
+        if self._position is not None :
+            self.seek(self._position)
+            self._position = None
+
+        else :
+            raise Exception("reset() without mark()")
+
+class ISeekableReadStream (ISeekableStream, IReadStream) :
+    def peek (self, len=None) :
+        """
+            Return a string representing what buf.read(len) would return, but do
+            not affect future read operations
+        """
+
+        pos = self.tell()
+
+        data = self.read(len)
+
+        self.seek(pos)
+        
+        return data
+    
+
+class INonBlockingReadStream (IReadStream) :
+    """
+        Otherwise identical to IReadStream, but read will either return size
+        bytes, or raise a NotEnoughDataError
+    """
+    
+    pass
+
+class IWriteStream (IStreamBase) :
+    """
+        IWriteStream provides various ways to write data to a byte stream
+    """
+
+    def write (self, data) :
+        """
+            Write the given bytes to the stream
+        """
+
+        abstract
+
+    def writeStruct (self, fmt, *args) :
+        """
+            Pack the given arguments with the given struct format, and write it
+            to the stream.
+        """
+
+        self.write(struct.pack(self.STRUCT_PREFIX + fmt, *args))
+
+    writeItem = writeStruct
+        
+    def writeVarLen (self, len_type, data) :
+        """
+            Write a <length><data> field into the buffer. Len_type is the
+            struct format code for the length field.
+        """
+
+        self.writeStruct(len_type, len(data))
+        self.write(data)
+
+    def writeEnum (self, enum, name) :
+        """
+            Write the single-byte value correspnding to the given name's
+            position in the given enum
+        """
+
+        self.writeStruct('B', enum.index(name))
+
+class IBufferBase (ISeekableStream) :
+    """
+        A buffer simply provides a way to read and write data to/from a byte
+        sequence stored in memory.
+    """
+
+    def tell (self) :
+        return self._buf.tell()
+    
+    def seek (self, offset) :
+        return self._buf.seek(offset)
+
+    def getvalue (self) :
+        """
+            Returns the value of the buffer, i.e. a string with the contents of
+            the buffer from position zero to the end.
+        """
+
+        return self._buf.getvalue()
+
+class ReadBuffer (INonBlockingReadStream, ISeekableReadStream, IBufferBase) :
+    """
+       A read-only buffer. Can be initialized with a given value and then later
+       replaced in various ways, but cannot be modified.
+    """
+
+    def __init__ (self, data="") :
+        """
+            Initialize the buffer with the given data
+        """
+
+        self._buf = StringIO(data)
+    
+    def read (self, size=None) :
+        """
+            Return the given number of bytes, or raise a NotEnoughDataError
+        """
+
+        if size == 0 :
+            raise ValueError("can't read zero bytes")
+         
+        if size :
+            ret = self._buf.read(size)
+        else :
+            ret = self._buf.read()
+
+        if size and len(ret) < size :
+            raise NotEnoughDataError()
+
+        return ret    
+    
+    def append (self, data) :
+        """
+            Modify the buffer such that it contains the old data from this
+            buffer, and the given data at the end. The read position in the buffer
+            is kept the same.
+        """
+
+        pos = self.tell()
+
+        self._buf = StringIO(self._buf.getvalue() + data)
+
+        self.seek(pos)
+
+    def chop (self) :
+        """
+            Discard the data in the buffer before the current read position.
+            Also removes any marks.
+        """
+
+        self._position = None
+        
+        self._buf = StringIO(self.read())
+
+    def processWith (self, func) :
+        """
+            Call the given function with this buffer as an argument after
+            calling mark(). If the function 
+                a) returns None, the buffer is .chop()'d, and we repeat the
+                   process.
+                b) raises a NotEnoughDataError, whereupon the buffer is rolled
+                   back to where it was before calling the function with 
+                   chop().
+                c) raises a StopIteration, whereupon we chop the buffer and 
+                   return.
+                d) returns something (i.e. ret is not None), whereupon we
+                   return that (and leave the current buffer position untouched).
+        """
+        ret = None
+        
+        try :
+            while ret is None :
+                self.mark()  # mark the position of the packet we are processing
+                ret = func(self)
+
+                if ret is None :
+                    # discard the processed packet and proceed to the next one
+                    self.chop()
+                
+        except NotEnoughDataError, e :
+            self.reset() # reset position back to the start of the packet
+            return e
+            
+        except StopIteration, e:
+            self.chop()
+            return e # processed ok, but we don't want to process any further packets
+            
+        else :
+            return ret
+
+class WriteBuffer (IWriteStream, IBufferBase) :
+    """
+        A write-only buffer. Data can be written to this buffer in various
+        ways, but cannot be read from it except as a whole.
+    """
+
+    def __init__ (self) :
+        """
+            Initialize the buffer
+        """
+
+        self._buf = StringIO()
+
+    def write (self, data) :
+        """
+            Write the given data to the current position in the stream,
+            overwriting any previous data in that position, and extending
+            the buffer if needed
+        """
+
+        return self._buf.write(data)
+
+def readStringStream (stream, varlen_type) :
+    """
+        Does readVarLen on an IReadStream until it returns something that evaluates to false ( == zero-length string)
+    """
+
+    while True :
+        item = stream.readVarLen(varlen_type)
+
+        if item :
+            yield item
+        else :
+            return
+
+def writeStringStream (stream, varlen_type, strings) :
+    """
+        Writes strings from the given iterable into the given stream using the given varlen_type, ending with a null-length token
+    """
+
+    for item in strings :
+        stream.writeVarLen(varlen_type, item)
+
+    stream.writeItem(varlen_type, 0)
+
+class StreamProtocol (object) :
+    """
+        A mixin to let you use Buffer with twisted.internet.protocol.Protocol
+    """
+    
+    # a list of receivable command names 
+    RECV_COMMANDS = None
+
+    # a list of sendable command names
+    SEND_COMMANDS = None
+
+    def __init__ (self) :
+        """
+            Initialize the cross-dataReceived buffer
+        """
+
+        self.in_buffer = ReadBuffer()
+
+    def send (self, buf) :
+        """
+            Write the contents of the given WriteBuffer to the transport
+        """
+
+        self.transport.write(buf.getvalue())
+
+    def dataReceived (self, data) :
+        """
+            Buffer the incoming data and then try and process it
+        """
+
+        self.in_buffer.append(data)
+        
+        ret = self.in_buffer.processWith(self.processPacket)
+        
+    def processPacket (self, buf) :
+        """
+            Call processCommand with the buffer, handling the return value (either None or a deferred)
+        """
+
+        ret = self.processCommand(buf)
+
+        if ret :
+            ret.addCallback(self.send)
+
+    def processCommand (self, buf) :
+        """
+            Process a command from the given buffer. May return a callback
+        """
+
+        return self.readMethod(buf, self.RECV_COMMANDS, buf)
+
+    # conveniance read/write
+    def startCommand (self, cmd) :
+        buf = WriteBuffer()
+        
+        buf.writeEnum(self.SEND_COMMANDS, cmd)
+
+        return buf
+    
+    def readMethod (self, buf, methods, *args, **kwargs) :
+        """
+            Reads a single-byte <methods>-enum value from the given buffer and
+            use it to find the corresponding method (as <prefix>_<method-name>,
+            prefix can be overriden with a keyword argument and defaults to
+            'on'. If any extra arguments are given, they will be passed to the
+            method.
+        """
+
+        prefix = kwargs.pop("prefix", "on")
+
+        return getattr(self, "%s_%s" % (prefix, buf.readEnum(methods)))(*args, **kwargs)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/example.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,24 @@
+from twisted.internet import protocol, reactor
+from twisted.python import log
+import sys
+
+import api
+
+class ExampleModule (api.Module) :
+    name = "example"
+    version = 0x0001
+    
+    event_types = [
+        "example"
+    ]
+
+    def handleConnect (self) :
+        self.sendEvent("example", "this is an example event")
+        self.disconnect()
+
+if __name__ == '__main__' :
+    log.startLogging(sys.stderr)
+    
+    module = ExampleModule()
+    reactor.run()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/fifo.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,100 @@
+# read a stream from a fifo
+
+from twisted.internet import reactor, interfaces
+from twisted.python import log
+from zope.interface import implements
+
+import os, fcntl, errno
+
+class EOF (Exception) : pass
+
+BUF_SIZE = 2048
+
+class Fifo (object) :
+    implements(interfaces.IReadDescriptor)
+
+    def __init__ (self, path) :
+        self.path = path
+        self._open()
+
+    def _open (self) :
+        self.fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
+
+        reactor.addReader(self)
+        
+        log.msg("opened fifo %s as %d" % (self.path, self.fd))
+
+    def _close (self) :
+        if self.fd :
+            reactor.removeReader(self)
+            os.close(self.fd)
+
+            log.msg("closed fifo %d at %s" % (self.fd, self.path))
+            
+            self.fd = None
+    close = _close
+
+    def reopen (self) :
+        """
+            Close and re-open the fifo. This is useful for handling EOF
+        """
+        self._close()
+        self._open()
+
+    def _read (self, length) :
+
+        log.msg("(read %d bytes from %d:%s)" % (length, self.fd, self.path))
+        try :
+            data = os.read(self.fd, length)
+
+        except OSError, e :
+            if e.errno == errno.EAGAIN :
+                log.msg("\tEAGAIN")
+                return None
+            else :
+                log.msg("\tERROR: %s" % e)
+                raise
+
+        if not data :
+            log.msg("\tEOF")
+            raise EOF()
+        
+        log.msg("\tDATA: %d: %r" % (len(data), data))
+        return data
+    
+    def fileno (self) :
+        return self.fd
+
+    def doRead (self) :
+        while True :
+            log.msg("fifo doRead loop")
+
+            try :
+                data = self._read(BUF_SIZE)
+            except EOF :
+                self.handleEOF()
+                return
+
+            if data :
+                self.dataReceived(data)
+            else :
+                break
+        
+    def dataReceived (self, data) :
+        pass
+    
+    def handleEOF (self) :
+        pass
+    
+    def connectionLost (self, reason) :
+        self.close()
+    
+    def logPrefix (self) :
+        return "FIFO:%d:%s" % (self.fd, self.path)
+
+    def __del__ (self) :
+        """
+            !!! this is important
+        """
+        self.close()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/irc.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,156 @@
+from twisted.words.protocols import irc
+from twisted.internet import protocol
+from twisted.python import log
+import traceback
+
+import buffer
+
+class ReplyException (Exception) :
+    def __init__ (self, reply) :
+        self.reply = reply
+
+class Client (irc.IRCClient, object) :
+    """
+        Fixme IRC bot
+    """
+    
+    def __init__ (self) :
+        # can't config yet
+        pass
+
+    # housekeeping
+    def connectionMade (self) :
+        self.nexus = self.factory.nexus
+
+        self.nickname = self.factory.nickname
+        self.username = self.factory.username
+        self.channel = self.factory.channel
+        
+        log.msg("IRC Client connected, using username=%s, nickname=%s" % (self.username, self.nickname))
+        
+        super(Client, self).connectionMade()
+
+    def connectionLost (self, reason) :
+        log.msg("Connection lost: %s" % reason)
+        super(Client, self).connectionLost(reason)
+
+    def signedOn (self) :
+        log.msg("Signed on, joining channel %s" % self.channel)
+        self.join(self.channel)
+
+    def joined (self, channel) :
+        log.msg("Joined channel %s" % channel)
+        
+        self.factory.connected(self)
+    
+    # our actual functionality
+    def send (self, msg) :
+        msg = str(msg)
+
+        if len(msg) > 480 :
+            log.msg("truncating: %s" % msg)
+            msg = msg[:480] + "..."
+
+        msg = msg.replace("\n", "\\n").replace("\r", "\\r").replace("\0", "\\0")
+
+        self.notice(CHANNEL, msg)
+
+    def sendEvent (self, event) :
+        self.send("[%s.%s] %s" % (event.module.name, event.type, event.msg))
+
+    def moduleConnected (self, module, addr) :
+        self.send("{modules.%s} connected from %s:%d, version %s" % (module.name, addr.host, addr.port, module.version))
+
+    def moduleDisconnected (self, module, reason) :
+        self.send("{modules.%s} disconnected: %s" % (module.name, reason))
+    
+    class _noDefault : pass
+
+    def _lookupCommand (self, command, default=_noDefault) :
+        if '.' in command :
+            raise ReplyException("No support for module commands yet :P")
+        else :
+            method = getattr(self, "cmd_%s" % command, None)
+
+        if method :
+            return method
+        elif default is self._noDefault :
+            raise ReplyException("No such command '%s'. See `help commands'" % command)
+        else :
+            return default
+
+    def privmsg (self, user, channel, message) :
+        if message.lower().startswith(self.nickname.lower() + ':'):
+            me, command = message.split(":", 1)
+
+            args = command.strip().split()
+            command = args.pop(0)
+            
+            try :
+                method = self._lookupCommand(command)
+
+                reply = method(*args)
+
+                if reply :
+                    self.send(reply)
+            
+            except ReplyException, e :
+                self.send(e.reply)
+
+            except Exception, e :
+                self.send("Error: %s: %s" % (e.__class__.__name__, e))
+                traceback.print_exc()
+
+    def cmd_help (self, cmd="help") :
+        """help <command|module> - Display help about the given command or module"""
+
+        method = self._lookupCommand(cmd, None)
+        
+        if method :
+            return method.__doc__
+        else :
+            try :
+                module, addr = self.nexus.getModuleInfo(cmd)
+
+                return "%s is version %d from %s:%d. Events: %s. See `commands %s' for a list of commands" % (module.name, module.version, addr.host, addr.port, ', '.join(module.event_types), module.name)
+
+            except KeyError :
+                raise ReplyException("No command/module called `%s'. See `help commands'" % cmd)
+
+    def cmd_commands (self, module=None) :
+        """commands [<module>] - Show primary commands, or commands in the given module (see `help modules')"""
+
+        if module :
+            raise ReplyException("No support for module commands yet :P")
+        else :
+            return "Commands: %s" % ', '.join(
+                attr_name.split('_', 1)[1]
+                for attr_name in Client.__dict__.iterkeys()
+                if attr_name.startswith("cmd_")
+            )
+
+    def cmd_modules (self) :
+        """modules - Show a list of connected modules"""
+
+        return "Modules: %s" % ', '.join(
+            module.name
+            for module in self.nexus.getModules()
+        )   
+
+class Factory (protocol.ClientFactory) :
+    protocol = Client
+
+    def __init__ (self, nexus, nickname, username, channel) :
+        self.nexus = nexus
+
+        # config
+        self.nickname = nickname
+        self.username = username
+        self.channel = channel
+        
+        # don't have a connection yet
+        self.connection = None
+    
+    def connected (self, connection) :
+        self.connection = connection
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/logwatch_filters.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,66 @@
+import re
+
+class FullFilter (object) :
+    def __init__ (self, event_type) :
+        self.event_type = event_type
+
+    def test (self, line) :
+        return line
+
+class NullFilter (object) :
+    def __init__ (self, pattern, flags=None) :
+        self.regexp = re.compile(pattern, flags)
+    
+    def test (self, line) :
+        match = self.regexp.search(line)
+        
+        if match :
+            return False
+
+class SimpleFilter (object) :
+    def __init__ (self, event_type, pattern, format) :
+        self.event_type = event_type
+
+        self.regexp = re.compile(pattern)
+        self.format = format
+
+    def test (self, line) :
+        match = self.regexp.search(line)
+        
+        if match :
+            return self._filter(match)
+        
+    def _filter (self, match) :
+        return self.format % match.groupdict()
+
+_timestamp = "\w{3} [0-9 ]\d \d{2}:\d{2}:\d{2}"
+
+all = FullFilter("all")
+
+all_wo_timestamps = SimpleFilter(
+    "all",
+    "^" + _timestamp + " (?P<line>.+)$",
+    "%(line)s"
+)
+
+sudo = SimpleFilter(
+    "sudo",
+    "(?P<hostname>\S+)\s+sudo:\s*(?P<username>\S+) : TTY=(?P<tty>\S+) ; PWD=(?P<pwd>.+?) ; USER=(?P<target_user>\S+) ; COMMAND=(?P<command>.*)",
+    "%(username)s:%(tty)s - %(target_user)s@%(hostname)s:%(pwd)s - %(command)r"
+)
+
+ssh = SimpleFilter(
+    "ssh",
+    "(?P<success>Accepted|Failed) password for (?P<username>\S+) from (?P<ip>\S+) port (?P<port>\S+) (?P<proto>\S+)",
+    "%(success)s login for %(username)s from %(ip)s:%(port)s proto %(proto)s"
+)
+
+cron_killer = NullFilter(
+        "^" + _timestamp + " \S+\s+(CRON|su)\[\d+\]: \(\w+\) session (opened|closed) for user \w+( by \(uid=\d+\))?$",
+        re.IGNORECASE
+)
+
+su_nobody_killer = NullFilter(
+    "^" + _timestamp + " \S+\s+su\[\d+\]: (Successful su for nobody by root|\+ \?\?\? root:nobody)$",
+    re.IGNORECASE
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/logwatch_sources.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,91 @@
+from twisted.internet import reactor, protocol
+from twisted.python import log
+
+import fifo
+
+class LogSource (object) :
+    def __init__ (self, name, filters) :
+        # set later on
+        self.module = None
+        
+        # what filters to apply
+        self.filters = filters
+        
+        # name, for display purposes
+        self.name = name
+
+        # used to gather data together into lines
+        self.buf = ""
+
+    def setModule (self, module) :
+        self.module = module
+
+    def handleError (self, msg) :
+        log.err(msg)
+        self.module.error(msg)
+
+    def handleData (self, data) :
+        data = self.buf + data
+        
+        while "\n" in data :
+            line, data = data.split("\n", 1)
+
+            self.handleLine(line)
+
+        self.buf = data
+
+    def handleLine (self, line) :
+        log.msg("Matching line `%s'..." % line)
+
+        for filter in self.filters :
+            out = filter.test(line)
+
+            if out :
+                log.msg("\t%s: %s" % (filter.event_type, out))
+                self.module.sendEvent(filter.event_type, out)
+
+                break
+            elif out is False :
+                return
+            else :  # None
+                continue
+
+class File (LogSource, protocol.ProcessProtocol) :
+    def __init__ (self, name, path, filters) :
+        super(File, self).__init__(name, filters)
+
+        self.path = path
+
+        log.msg("spawning tail process for %s:%s" % (name, path))
+
+        reactor.spawnProcess(self, "/usr/bin/tail", ["tail", "-n0", "--follow=name", path])
+
+    def errReceived (self, data) :
+        self.handleError("tail for %s: %s" % (self.name, data))
+
+    def outReceived (self, data) :
+        self.handleData(data)
+
+    def processEnded (self, reason) :
+        self.handleError("tail process for %s quit: %s" % (self.name, reason.getErrorMessage()))
+
+class Fifo (LogSource, fifo.Fifo) :
+    def __init__ (self, name, path, filters) :
+        LogSource.__init__(self, name, filters)
+
+        log.msg("opening fifo for %s:%s" % (name, path))
+
+        fifo.Fifo.__init__(self, path)
+    
+    def dataReceived (self, data) :
+        self.handleData(data)
+
+    def handleEOF (self) :
+        self.handleError("!!! EOF on fifo %s, re-opening" % self.name)
+        self.reopen()
+    
+    def connectionLost (self, reason) :
+        super(Fifo, self).connectionLost(reason)
+        self.handleError("lost fifo for %s: %s" % (self.name, reason.getErrorMessage()))
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/logwatcher.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,28 @@
+from twisted.internet import protocol, reactor
+from twisted.python import log
+import sys
+
+import api
+import logwatch_config as config
+
+class LogWatchModule (api.Module) :
+    name = "logs"
+    version = 0x0005
+    
+    event_types = [
+        "error",
+        "sudo",
+        "ssh",
+        "all"
+    ]
+    
+    def handleConnect (self) :
+        for source in config.sources() :
+            source.setModule(self)
+    
+    def error (self, msg) :
+        self.sendEvent("error", msg)
+
+if __name__ == '__main__' :
+    LogWatchModule().run()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/nexus.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,70 @@
+from twisted.application import internet, service
+from twisted.internet import reactor, protocol
+from twisted.python import log
+import sys
+
+import irc, api
+
+class Nexus (object) :
+    def __init__ (self) :
+        """
+            Must set .irc/.api attrs to irc.Factory/api.ServerFactory instances
+        """
+
+        self.modules = dict()
+
+
+    def registerModule (self, module, transport) :
+        self.modules[module.name] = (module, transport)
+
+        self.irc.connection.moduleConnected(module, transport.transport.getPeer())
+
+    def unregisterModule (self, module, reason) :
+        del self.modules[module.name]
+
+        self.irc.connection.moduleDisconnected(module, reason)
+    
+    def handleEvent (self, event) :
+        self.irc.connection.sendEvent(event)
+
+    def getModules (self) :
+        return (module for (module, transport) in self.modules.itervalues())
+
+    def getModuleInfo (self, name) :
+        module, connection = self.modules[name]
+
+        return module, connection.transport.getPeer()
+ 
+def makeService (config) :
+    n = Nexus()
+    s = service.MultiService()
+    
+    # the IRC side
+    n.irc = irc.Factory(n, 
+        config['irc-nickname'], 
+        config['irc-username'],
+        config['irc-channel']
+    )
+    
+    log.msg("Connecting to IRC server on [%s:%d]", config['irc-hostname'], config['irc-port'])
+    irc_client = internet.TCPClient(config['irc-hostname'], config['irc-port'], n.irc)
+
+    irc_client.setServiceParent(s)
+
+    # the API side
+    n.api = api.ServerFactory(n)
+    
+    log.msg("Starting API server on [%s:%d]", config['api-port'], config['api-listen'])
+    api_server = internet.TCPServer(config['api-port'], n.api, interface=config['api-listen'])
+
+    api_server.setServiceParent(s)
+
+    # return the service collection
+    return s
+       
+if __name__ == '__main__' :
+    log.startLogging(sys.stderr)
+
+    nexus = Nexus()
+    reactor.run()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/setup.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,10 @@
+from distutils.core import setup, Extension
+
+_utmp = Extension('_utmp', sources=['_utmp.c'])
+
+setup(
+    name        = "FixBot",
+    version     = "0.1",
+    description = "IRC bot for sysadmin things",
+    ext_modules = [_utmp],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/tap.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,25 @@
+from twisted.python import usage
+
+import nexus
+
+class NexusOptions (usage.Options) :
+    optParameters = [
+        (   "uid",          "u",    "fixbot",           "user to run as"                                ),
+        (   "gid",          "g",    "nogroup",          "group to run as"                               ),
+        (   "irc-hostname", "s",    "irc.fixme.fi",     "IRC server hostname",                          ),
+        (   "irc-port",     "p",    6667,               "IRC server port",                      int     ),
+        (   "irc-nickname", "n",    "FixBotDev",        "IRC nickname",                                 ),
+        (   "irc-username", "U",    "fixbot",           "IRC username",                                 ),
+        (   "irc-channel",  "c",    "#fixme-test",      "IRC channel",                                  ),
+        (   "api-listen"    "l",    "127.0.0.1",        "address for API server to listen on"           ),
+        (   "api-port",     "P",    34888,              "port for API server to listen on",     int     ),
+    ]
+
+    optFlags = [
+
+    ]
+
+def makeService (config) :
+    #    app = service.Application('fixbot', uid=config['uid'], gid=config['gid'])
+    return nexus.makeService(config)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fixbot/utmp.py	Mon Sep 15 00:27:05 2008 +0300
@@ -0,0 +1,125 @@
+from twisted.internet import protocol, reactor
+from twisted.python import log
+import socket, os, time
+
+import _utmp, api, sys
+
+POLL_INTERVAL = 5
+
+def getPidCmd (pid) :
+    try :
+        fh = open("/proc/%d/cmdline" % pid, "r")
+        cmdline = fh.read()
+        fh.close()
+
+        return repr("%d:%s" % (pid, cmdline))
+    except IOError, OSError :
+        return "[%d]" % pid
+
+class WtmpEntry (object) :
+    _ATTRS = (
+        "type", "pid", "line", "id", "user", "host", "exit", "session", "tv", "addr"
+    )
+
+    @staticmethod
+    def fromFile (file) :
+        bytes = file.read(_utmp.size())
+
+        if not bytes :
+            return
+
+        result = _utmp.parse(bytes)
+        
+        wtmp = WtmpEntry()
+
+        for name, field in zip(wtmp._ATTRS, result) :
+            if isinstance(field, str) and name not in ("addr", ) :
+                field = field.rstrip("\0")
+
+            setattr(wtmp, name, field)
+        
+        # convert the address
+        family = socket.AF_INET
+        addr = wtmp.addr[0:4]
+        for byte in wtmp.addr[4:] :
+            if byte :
+                family = socket.AF_INET6
+                addr = wtmp.addr
+                break
+        
+        wtmp.addr = socket.inet_ntop(family, addr)
+        wtmp.pid = getPidCmd(wtmp.pid)
+
+        return wtmp
+
+    def __str__ (self) :
+        return " ".join("%s=%s" % (key, getattr(self, key)) for key in self._ATTRS)
+
+class WtmpFile (object) :
+    def __init__ (self, path="/var/log/wtmp") :
+        self.path = path
+        self.file = open(path)
+        self.file.seek(0, os.SEEK_END)
+
+    def tryRead (self) :
+        return WtmpEntry.fromFile(self.file)
+
+def read_entries (file) :
+    while True :
+        wtmp = read_entry(file)
+
+        if wtmp :
+            yield wtmp
+        else :
+            return
+
+def follow_main () :
+    wtmp = WtmpFile()
+    
+    while True :
+        item = wtmp.tryRead()
+
+        if item :
+            print item
+        else :
+            time.sleep(2)
+
+def test_main () :
+    fh = open("/var/log/wtmp", "r")
+    
+    for item in read_entries(fh) :
+        print item
+
+class WtmpModule (api.Module) :
+    name = "wtmp"
+    version = 0x0001
+    
+    event_types = [
+        "wtmp",
+    ]
+
+    def handleConnect (self) :
+        log.msg("Following default wtmp file...")
+
+        self.wtmp = WtmpFile()
+
+        log.msg("Starting poll timer")
+
+        self.poll()
+
+        log.msg("Running")
+
+    def poll (self) :
+
+        while True :
+            item = self.wtmp.tryRead()
+
+            if item :
+                log.msg(" -- %s" % item)
+                self.sendEvent("wtmp", str(item))
+            else :
+                return reactor.callLater(POLL_INTERVAL, self.poll)
+
+if __name__ == '__main__' :
+    WtmpModule().run()
+