pvl.irker: irker reimplementation in Twisted
authorTero Marttila <terom@paivola.fi>
Fri, 11 Jan 2013 17:13:11 +0200
changeset 81 448ed86d0510
parent 80 231d3de7081a
child 82 4383c996156e
pvl.irker: irker reimplementation in Twisted
bin/pvl.irk
bin/pvl.irker
pvl/irker/__init__.py
pvl/irker/irc.py
pvl/irker/irk.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/pvl.irk	Fri Jan 11 17:13:11 2013 +0200
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+
+"""
+    Test Irk
+"""
+
+__version__ = '0.1'
+
+import pvl.args
+import pvl.irk
+
+import sys, pvl.syslog.tail # XXX: for sys.stdin
+
+import logging, optparse
+
+log = logging.getLogger('main')
+
+def parse_options (argv) :
+    """
+        Parse command-line arguments.
+    """
+
+    prog = argv[0]
+
+    parser = optparse.OptionParser(
+            prog        = prog,
+            usage       = '%prog: [options]',
+            version     = __version__,
+
+            # module docstring
+            description = __doc__,
+    )
+    
+    # options
+    parser.add_option_group(pvl.args.parser(parser))
+
+    # input
+    parser.add_option_group(pvl.irk.parser(parser))
+
+    # parse
+    options, args = parser.parse_args(argv[1:])
+    
+    # apply
+    pvl.args.apply(options, prog)
+
+    return options, args
+
+def main (argv) :
+    options, args = parse_options(argv)
+
+    log.info("Connect IRK..")
+    irker = pvl.irk.apply(options)
+
+    log.info("Load targets...")
+    targets = [irker.target(target) for target in args]
+
+    log.info("Send messages...")
+    for line in pvl.syslog.tail.Tail(sys.stdin) :
+        log.info("%s", line)
+
+        for target in targets :
+            target(line)
+
+    # done
+    log.info("Exiting...")
+    return 0
+
+if __name__ == '__main__':
+    import sys
+
+    sys.exit(main(sys.argv))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/pvl.irker	Fri Jan 11 17:13:11 2013 +0200
@@ -0,0 +1,117 @@
+#!/usr/bin/python
+
+from twisted.internet import reactor, endpoints, defer
+from twisted.python import usage, log
+
+import urlparse
+
+import pvl.irker.irk
+import pvl.irker.irc
+
+class Options (usage.Options) :
+    optParameters = [
+            [ 'irc-nickname', 'n',  pvl.irker.irc.IRCFactory.NICKNAME, "Default IRC nickname" ],
+    ]
+
+    def __init__ (self) :
+        usage.Options.__init__(self)
+
+        self.listen = []
+        self.connect = []
+        self.target = []
+        self.privmsg = []
+
+    def opt_listen_tcp (self, listen) :
+        """
+            Twisted endpoint.
+        """
+
+        self.listen.append((endpoints.TCP4ServerEndpoint, (int(listen), )))
+
+    def opt_connect (self, connect) :
+        """
+            Connect to given target.
+        """
+
+        self.connect.append(urlparse.urlparse(connect))
+
+    def opt_target (self, target) :
+        """
+            Join given target.
+        """
+
+        self.target.append(urlparse.urlparse(target))
+
+    def opt_privmsg (self, privmsg) :
+        """
+            Send message to targets
+        """
+
+        self.privmsg.append(privmsg)
+
+@defer.inlineCallbacks
+def connect (irc, connect) :
+    """
+        Connect to given urls.
+    """
+            
+    try :
+        clients = yield defer.gatherResults([irc.client(url) for url in connect])
+
+    except Exception as ex :
+        log.err(ex)
+        return
+
+    for client in clients :
+        log.msg('--connect', client)
+
+@defer.inlineCallbacks
+def target (irc, target, privmsg) :
+    """
+        Connect to given urls.
+    """
+            
+    try :
+        targets = yield defer.gatherResults([irc.target(url) for url in target])
+
+    except Exception as ex :
+        log.err(ex)
+        return
+
+    for target in targets :
+        log.msg('--target', target)
+
+        target.privmsg(*privmsg)
+
+def main (args) :
+    options = Options()
+    options.parseOptions(args)
+
+    # logging
+    log.startLogging(sys.stderr, setStdout=False)
+
+    # connect
+    irc = pvl.irker.irc.IRCFactory()
+    
+    connect(irc, options.connect)
+    target(irc, options.target, options.privmsg)
+
+    # listen
+    irk = pvl.irker.irk.IrkFactory(irc)
+
+    for endpoint, args in options.listen :
+        endpoint = endpoint(reactor, *args)
+
+        log.msg("listen:", endpoint)
+
+        endpoint.listen(irk)
+
+
+    # go
+    reactor.run()
+
+    return 0
+
+if __name__ == '__main__' :
+    import sys
+    sys.exit(main(sys.argv[1:]))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/irker/__init__.py	Fri Jan 11 17:13:11 2013 +0200
@@ -0,0 +1,5 @@
+"""
+    Twisted-based irker implementation.
+"""
+
+__version__ = '0.1dev'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/irker/irc.py	Fri Jan 11 17:13:11 2013 +0200
@@ -0,0 +1,389 @@
+"""
+    IRC client, dispatching irker messages.
+"""
+
+from twisted.internet import reactor, interfaces, protocol, defer, error
+from twisted.words.protocols import irc
+
+from twisted.internet import endpoints
+
+from twisted.python import log
+
+PORT = 6667
+
+def url2endpoint (reactor, url) :
+    """
+        Turn given urlparse URL into an endpoint.
+
+        Raises KeyError on unknown scheme.
+    """
+
+    SCHEMES = {
+            'irc':      lambda : endpoints.TCP4ClientEndpoint(reactor, url.hostname, url.port or PORT),
+    }
+    
+    return SCHEMES[url.scheme]()
+
+def normalize (name) :
+    """
+        Normalize a channel/nickname for comparisons in IRC.
+    """
+
+    return name.lower()
+
+class IRCError (Exception) :
+    """
+        A handled protocol error.
+    """
+
+    pass
+
+class IRCChannel (object) :
+    """
+        A joined channel on an IRC server.
+    """
+
+    ENCODING = 'utf-8'
+
+    def __init__ (self, client, channel, encoding=ENCODING) :
+        self.client = client
+        self.channel = channel
+
+        self.encoding = encoding
+
+    def privmsg (self, *msgs) :
+        for msg in msgs :
+            # XXX: encode
+            self.client.msg(self.channel, msg.encode(self.encoding))
+
+    def errback (self, failure) :
+        """
+            Fail any pending requests.
+        """
+
+        log.msg('IRCChannel.errback', self, failure)
+
+    def __str__ (self) :
+        return self.client.url(self.channel)
+
+class IRCClient (irc.IRCClient) :
+    """
+        A connection to an IRC server with a specific, requested nickname.
+
+        Joins to channels.
+    """
+
+    performLogin = False
+
+    def __init__ (self, factory) :
+        self.factory = factory
+        
+        self.nickname = None
+        self.hostname = None
+
+        self._registering = None
+        self._channels = { }
+
+    def connectionMade (self) :
+        self.hostname = self.transport.getPeer().host
+        self.transport.logPrefix = self.logPrefix
+
+        log.msg("connectionMade", self, self.transport)
+        irc.IRCClient.connectionMade(self)
+
+    def sendLine (self, line) :
+        irc.IRCClient.sendLine(self, line)
+
+        log.msg(">>>", line)
+
+    def lineReceived (self, line) :
+        log.msg("<<<", line)
+
+        irc.IRCClient.lineReceived(self, line)
+        
+    ## Register
+    def register (self, nickname, username=None, password=None) :
+        """
+            Register to the server, choosing a nickname based on the given nickname.
+
+            Returns a Deferred that callbacks with our actual nickname once we have registered, or errbacks with an IRCError.
+        """
+
+        if self._registering :
+            raise Exception("register: already registering")
+
+        self.username = username
+        self.password = password
+        
+        log.msg("register", nickname)
+        irc.IRCClient.register(self, nickname)
+
+        # defer
+        d = self._registering = defer.Deferred()
+
+        return d
+    
+    # irc_ERR_NICKNAMEINUSE
+    # alterCollidedNick
+    # irc_ERR_ERRONEUSNICKNAME
+
+    def irc_ERR_PASSWDMISMATCH (self, prefix, params) :
+        err = IRCError('ERR_PASSWDMISMATCH')
+        log.err(err)
+        self._registering.errback(err)
+
+    def irc_RPL_WELCOME (self, prefix, params) :
+        self.hostname = prefix
+        irc.IRCClient.irc_RPL_WELCOME(self, prefix, params)
+
+    def signedOn (self) :
+        log.msg("signedOn", self.nickname)
+        irc.IRCClient.signedOn(self)
+        
+        # defer
+        d = self._registering
+
+        if not d :
+            raise Exception("signedOn: not registering?")
+
+        self._registering = None
+
+        d.callback(self.nickname)
+    
+    ## Channels
+    def join (self, channel, key=None) :
+        """
+            Join the given channel.
+
+            Returns a deferred that callbacks with the IRCChannel once joined, or errbacks.
+        """
+
+        irc.IRCClient.join(self, channel, key=key)
+
+        d = self._channels[normalize(channel)] = defer.Deferred()
+
+        return d
+
+    # ERR_CHANNELISFULL
+    # ERR_INVITEONLYCHAN
+    # ERR_BANNEDFROMCHAN
+    # ERR_BADCHANNELKEY
+
+    def joined (self, channel) :
+        """
+            Have joined given channel.
+        """
+        
+        lookup = normalize(channel)
+
+        d = self._channels[lookup]
+        channel = self._channels[lookup] = IRCChannel(self, channel)
+        d.callback(channel)
+
+    @defer.inlineCallbacks
+    def channel (self, channel, key=None) :
+        """
+            Defer a joined IRCChannel.
+        """
+
+        lookup = normalize(channel)
+        
+        log.msg('IRCClient.channel', lookup, channel)
+
+        if lookup not in self._channels :
+            channel = yield self.join(channel, key)
+        else :
+            # wait or get
+            yield self._channels[lookup]
+
+        channel = self._channels[lookup]
+
+        log.msg('IRCClient.channel', lookup, channel)
+        
+        defer.returnValue(channel)
+
+    ## 
+    def irc_ERR_CANNOTSENDTOCHAN (self, prefix, params) :
+        nick, channel, error = params
+
+        log.err(IRCError(channel, error))
+
+    ## Quit
+    def irc_ERROR (self, prefix, params) :
+        msg, = params
+
+        log.err(IRCError(None, msg))
+
+    def connectionLost (self, reason) :
+        irc.IRCClient.connectionLost(self, reason)
+        log.err(reason)
+
+        if self._registering :
+            self._registering.errback(reason)
+            self._registering = None
+        
+        # unregister channels
+        for channel in self._channels :
+            # errback Deferred or IRCChannel
+            self._channels[channel].errback(reason)
+        
+        self._channels = { }
+
+        # unregister client
+        self.factory.clientLost(self)
+    
+    ## Logging
+    def url (self, target=None) :
+        """
+            Format as URL.
+        """
+        
+        # XXX: no isinstance() support
+        if interfaces.ITCPTransport.providedBy(self.transport) :
+            scheme = 'irc'
+        else :
+            # TODO: ssl?
+            scheme = None
+        
+        peer = self.transport.getPeer()
+
+        if peer.port == PORT :
+            peer = "{irc.hostname}".format(irc=self, peer=peer)
+        else :
+            peer = "{irc.hostname}:{peer.port}".format(irc=self, peer=peer)
+
+        if target :
+            path = str(target)
+        else :
+            path = None
+        
+        return ''.join(part for part in (
+            scheme, '://' if scheme else None,
+            self.nickname, '@' if self.nickname else None,
+            peer,
+            '/' if path else None, path
+        ) if part)
+
+    __str__ = url
+    logPrefix = url
+
+class IRCFactory (protocol.ClientFactory) :
+    """
+        Manage Clients and Targets
+    """
+
+    NICKNAME = 'irker'
+
+    def __init__ (self, nickname=NICKNAME) :
+        # default nickname
+        self.nickname = nickname
+
+        # (scheme, host, port, nick) -> IRCClient
+        self.clients = {}
+    
+    def buildProtocol (self, addr) :
+        return IRCClient(self)
+
+    def clientLost (self, client) :
+        """
+            Given IRCClient is no more.
+        """
+
+        log.msg("IRCFactory.clientLost", client)
+        
+        # remove from our clients
+        self.clients = dict((k, c) for k, c in self.clients.iteritems() if c != client)
+
+    @defer.inlineCallbacks
+    def connect (self, url) :
+        """
+            Defer a connected, registered Client for given URL.
+        """
+        
+        endpoint = url2endpoint(reactor, url)
+
+        log.msg('IRCFactory.connect', url, ':', endpoint)
+        
+        # connect
+        try :
+            client = yield endpoint.connect(self)
+        
+        except error.ConnectError as ex :
+            log.err(ex, ': '.join(str(x) for x in ('IRCFactory.connect', url, endpoint)))
+            raise
+        
+        else :
+            log.msg('IRCFactory.connect', url, ':', endpoint, ':', client)
+        
+        # register
+        try :
+            nickname = yield client.register(url.username or self.nickname, url.password)
+
+        except Exception as ex :
+            log.err("register", ex)
+            raise
+        
+        log.msg('IRCFactory.connect', url, ':', endpoint, ':', client, ':', nickname)
+
+        # okay!
+        defer.returnValue(client)
+
+    @defer.inlineCallbacks
+    def client (self, url) :
+        """
+            Return IRCClient for given URL.
+        """
+
+        lookup = (url.scheme, url.hostname, url.port, url.username)
+
+        if lookup not in self.clients :
+            # deferred for connect
+            connect = self.clients[lookup] = self.connect(url)
+            
+            try :
+                # wait on deferred, and then store IRCClient
+                self.clients[lookup] = yield connect
+
+            except Exception as ex :
+                # failed, remove the attempted connect
+                del self.clients[lookup]
+                raise
+
+        else :
+            # wait for result, if deferred
+            # XXX: this yields None, since the first inlineCallbacks yielding on the deferred returns None in its callback
+            yield self.clients[lookup]
+        
+        # connected client
+        client = self.clients[lookup]
+
+        log.msg('IRCFactory.client', url, ":", client)
+
+        defer.returnValue(client)
+
+    @defer.inlineCallbacks
+    def target (self, url) :
+        """
+            Return IRCChannel for given URL.
+        """
+
+        client = yield self.client(url)
+        
+        channel = '#' + url.path.lstrip('/')
+        channel = yield client.channel(channel)
+        
+        log.msg('IRCFactory.target', url, ":", channel)
+
+        defer.returnValue(channel)
+
+    @defer.inlineCallbacks
+    def privmsg (self, url, *msg) :
+        """
+            Dispatch given messages to given target.
+        """
+
+        target = yield self.target(url)
+        
+        log.msg('IRCFactory.privmsg', url, ":", target, ':', *msg)
+
+        target.privmsg(*msg)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/irker/irk.py	Fri Jan 11 17:13:11 2013 +0200
@@ -0,0 +1,91 @@
+"""
+    Irker protocol implementation.
+"""
+
+from twisted.internet import protocol, defer
+from twisted.protocols import basic
+from twisted.python import log
+
+import json, urlparse
+
+class Irk (basic.LineOnlyReceiver) :
+    """
+        A connected Irk client.
+    """
+
+    delimiter = '\n'
+    
+    def connectionMade (self) :
+        self.transport.logPrefix = self.logPrefix
+        
+        log.msg("connected", self)
+    
+    def connectionLost (self, reason) :
+        log.err("connection lost", reason)
+    
+    def error (self, *args) :
+        log.err(*args)
+        self.transport.loseConnection()
+
+    def lineReceived (self, line) :
+        """
+            JSON -> in
+        """
+
+        try :
+            irk = json.loads(line)
+
+        except ValueError as ex :
+            # invalid
+            return self.error(ex, line)
+        
+        # dispatch
+        self.factory.irkReceived(irk).addErrback(self.error, line)
+
+    def __str__ (self) :
+        host = self.transport.getHost()
+        peer = self.transport.getPeer()
+
+        return "{host.host}:{host.port}:{peer.host}".format(host=host, peer=peer)
+
+    logPrefix = __str__
+
+class IrkFactory (protocol.ServerFactory) :
+    """
+        Manage connected Irk clients.
+    """
+
+    protocol = Irk
+
+    def __init__ (self, irc) :
+        self.irc = irc
+    
+    @defer.inlineCallbacks
+    def irkReceived (self, irk) :
+        """
+            Deffered to handle lookup of target, and then sending message.
+
+            Errbacks on failures.
+        """
+
+        log.msg(str(irk))
+
+        if not 'to' in irk :
+            raise ValueError("missing target: to")
+        
+        # MUST NOT be unicode
+        # XXX: ValueError?
+        url = urlparse.urlparse(str(irk['to']))
+        
+        # connect, join, etc.
+        target = yield self.irc.target(url)
+        
+        # privmsg?
+        privmsg = irk.get('privmsg')
+
+        if privmsg :
+            # MUST be unicode
+            privmsg = unicode(privmsg)
+            
+            # dispatch
+            target.privmsg(privmsg)