]> git.zarvox.org Git - imoo.git/commitdiff
First pass at a tornado-powered async IRC client
authorDrew Fisher <drew@aerofs.com>
Mon, 17 Mar 2014 01:31:16 +0000 (21:31 -0400)
committerDrew Fisher <drew@aerofs.com>
Mon, 17 Mar 2014 02:41:04 +0000 (22:41 -0400)
All it does is connect and answer pings, but it can handle the basics of
the protocol.

It can be subclassed to build something which is interested in hooking
into all the messages received.

imoo/ircclient.py [new file with mode: 0644]

diff --git a/imoo/ircclient.py b/imoo/ircclient.py
new file mode 100644 (file)
index 0000000..13cdbb5
--- /dev/null
@@ -0,0 +1,152 @@
+import tornado.iostream
+import socket
+
+_MAX_MESSAGE_OCTETS = 512
+
+class IRCClient(object):
+    """
+    A terribly-incomplete async IRC client, built atop tornado's IOStream.
+
+    Right now it mostly just connects to a server, identifies, parses
+    commands, and answers pings.
+    """
+    def __init__(self, host="127.0.0.1", port=6667, tracing=False):
+        self.username = b"amy" # pay homage to those who came before us
+        self.host = host
+        self.port = port
+        self.socket = None
+        self.stream = None
+        self.tracing = tracing
+
+    def __repr__(self):
+        return "<IRCClient to {}:{}>".format(self.host, self.port)
+
+    def connect(self):
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
+        self.stream = tornado.iostream.IOStream(self.socket)
+        self.stream.connect((self.host, self.port), self._on_connect)
+
+    def _on_connect(self):
+        # Send initial login commands
+        self._send_nick()
+        self._send_user()
+        # Set up reply handler
+        self.stream.read_until(b'\r\n', self._on_line_received)
+
+    def _send_nick(self):
+        # NICK <nickname>
+        self.send_command(b'NICK', self.username)
+
+    def _send_user(self):
+        # USER <username> <hostname> <servername> <realname>
+        self.send_command(b'USER', self.username, b'localhost', b'localhost', b'Web gateway')
+
+    def _on_line_received(self, data):
+        """
+        This function is the primary state machine dispatcher.
+        """
+        if self.tracing:
+            print b"read:",
+            print b">>", data,
+
+        # Parse and dispatch the line (minus the CRLF).
+        self._parse_and_handle(data[:-2])
+
+        # Schedule self to handle next line too.
+        self.stream.read_until(b'\r\n', self._on_line_received)
+
+    def _parse_and_handle(self, data):
+        # Parse line and run the action handler.
+        fragment = data
+
+        # Extract prefix, if present
+        if fragment.startswith(b':'):
+            prefix, fragment = fragment.split(b' ', 1)
+            fragment.lstrip()
+        else:
+            prefix = ""
+
+        # Note that command can be either textual or 3-digit numeric
+        command, fragment = fragment.split(b' ', 1)
+        fragment.lstrip()
+
+        # Extract list of params
+        params = []
+        while fragment:
+            if fragment.startswith(b':'):
+                # Trailing param, consumes the rest of the line
+                params.append(fragment[1:])
+                break
+            # "middle" param
+            if b' ' in fragment:
+                param, fragment = fragment.split(b' ', 1)
+                fragment.lstrip()
+                params.append(param)
+            else:
+                params.append(fragment)
+                break
+
+        self.handle_command(prefix, command, params)
+
+    def handle_command(self, prefix, command, params):
+        """
+        The IRC-command level message handler.  This can be overridden by a
+        child class which wants to hook in additional handlers.
+        """
+        if self.tracing:
+            print b"Parsed line:"
+            print b"\tprefix:", prefix
+            print b"\tcommand:", command
+            print b"\tparams:"
+            for p in params:
+                print b'\t\t', p
+        # Handle pings.
+        if command == b"PING":
+            self.send_command(b"PONG", *params)
+
+        # TODO: implement more actions in response to interesting commands
+        # TODO: consider table-driven event handling?
+
+    def send_command(self, command, *params):
+        # RFC 1459 limits client commands to strictly textual commands
+        if not command.isalpha():
+            raise ValueError(b"Asked to send command '{}', but client commands must match [A-Za-z]+".format(command))
+        # TODO: check that command is a known command from the RFC?
+
+        # RFC 1459 limits params to 15 items
+        if len(params) > 15:
+            raise ValueError(b"Parameters list must be 15 items or less (yours was {})".format(len(params)))
+
+        # Check that params don't include any forbidden characters
+        for i in xrange(len(params)):
+            if i != len(params)-1:
+                if " " in params[i]:
+                    raise ValueError(b"Whitespace not allowed in parameters other than the final one (found whitespace in parameter {}: '{}')".format(i, params[i]))
+            if "\x00" in params[i]:
+                raise ValueError(b"NUL not allowed in parameter values (found in parameter {}: '{}')".format(i, params[i]))
+            if "\r" in params[i]:
+                raise ValueError(b"CR not allowed in parameter values (found in parameter {}: '{}')".format(i, params[i]))
+            if "\n" in params[i]:
+                raise ValueError(b"LF not allowed in parameter values (found in parameter {}: '{}')".format(i, params[i]))
+
+        # N.B. *args come in as a tuple, not a list, and tuples are immutable
+        paramlist = list(params)
+        if len(params) > 0:
+            paramlist[-1] = b':' + paramlist[-1]
+        paramstring = b" ".join(paramlist)
+        msg = b"{} {}\r\n".format(command, paramstring)
+
+        # Once serialized, verify that the command itself fits in the RFC's message length limit
+        if len(msg) > _MAX_MESSAGE_OCTETS:
+            raise ValueError(u"message was too long: {} octets (max allowed by IRC spec is {})".format(len(msg), _MAX_MESSAGE_OCTETS))
+
+        # Actually write the message to the stream.
+        if self.tracing:
+            print "write: <<", msg,
+        self.stream.write(msg)
+
+if __name__ == "__main__":
+    ircclient = IRCClient(tracing=True)
+    ircclient.connect()
+    import tornado.ioloop
+    tornado.ioloop.IOLoop.instance().start()