Eliminate the dependency on irclib.
authorEric S. Raymond <esr@thyrsus.com>
Sun, 20 Oct 2013 21:01:40 +0000 (17:01 -0400)
committerEric S. Raymond <esr@thyrsus.com>
Sun, 20 Oct 2013 21:01:40 +0000 (17:01 -0400)
Makefile
NEWS
install.txt
irkerd
irkerd.xml

index c99472cd07972345de925e8f51a2c3486ba780e9..fe93aaf5ff3cd52598efacd065df9cf9fc5d135c 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ clean:
        rm -f irkerd.8 irkerhook.1 irker-*.tar.gz *~
        rm -f SHIPPER.* *.html
 
-PYLINTOPTS = --rcfile=/dev/null --reports=n --include-ids=y --disable="C0103,C0111,C0301,R0201,R0902,R0903,R0912,R0914,R0915,E1101,W0201,W0212,W0621,W0702,F0401"
+PYLINTOPTS = --rcfile=/dev/null --reports=n --include-ids=y --disable="C0103,C0111,C0301,R0201,R0902,R0903,R0912,R0913,R0914,R0915,E1101,W0201,W0212,W0621,W0702,F0401"
 pylint:
        @pylint --output-format=parseable $(PYLINTOPTS) irkerd
        @pylint --output-format=parseable $(PYLINTOPTS) irkerhook.py
diff --git a/NEWS b/NEWS
index bf7e94404afd2cc9be5db1977e95385366b5b468..957e55aa362292f3bce288068bb0fb6354fd04c5 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -1,9 +1,10 @@
                        irker history 
 
-1.21 @ 
+2.0 @ 
   An email delivery method, suitable for use on SourceForge.
   irkerhook can now be used as a hg changegroup hook.
   Prevent misbehavior un UTF-8 in commit metadata.
+  The dependency on irclib is gone.
 
 1.20 @ 2013-05-17
   Compatibility back to Python 2.4 (provided simplejson is present).
index 75ca4e74a8bddc2c93d60b0ebbe48740c888cb3f..aeadf034650d26b13d577c7d88f02e3f12a7e38c 100644 (file)
@@ -38,9 +38,6 @@ to show all traffic with IRC servers.
 You should *not* make irker visible from outside the site firewall, as
 it can be used to spam IRC channels while masking the source address.
 
-You will need to have Jason Coombs's irc library where Python can see
-it.  See <http://pypi.python.org/pypi/irc/>; use version 3.4 or later.
-
 Some irclib versions after 5.0 may produce problems if you try to ship
 non-ASCII Unicode through them; this is not an irker bug, and should be
 kicked upstrean to the irclib maintainer.
@@ -48,6 +45,8 @@ kicked upstrean to the irclib maintainer.
 The file org.catb.irkerd.plist is a Mac OS/X plist that can be
 installed to launch irkerd as a boot-time service on that system.
 
+irkerd no longer requires irclib as it did in the 1.x versions.
+
 == Installing irkerhook.py ==
 
 Under git, a call to irkerhook.py should be installed in the update 
diff --git a/irkerd b/irkerd
index dd907fbd11b2aca63179f5a30b09d3cf7ba2c147..ec86d80fab38ebdb18b5e49496880a3fa50b7c83 100755 (executable)
--- a/irkerd
+++ b/irkerd
@@ -20,11 +20,9 @@ option prints the program version and exits.
 Design and code by Eric S. Raymond <esr@thyrsus.com>. See the project
 resource page at <http://www.catb.org/~esr/irker/>.
 
-Requires Python 2.6 or 2.5 with the simplejson library installed, and
-the irc client library at version >= 3.4 which requires 2.6: see
-
-http://pypi.python.org/pypi/irc/
+Requires Python 2.6 or 2.5 with the simplejson library installed.
 """
+
 from __future__ import with_statement
 
 # These things might need tuning
@@ -48,8 +46,8 @@ CONNECTION_MAX = 200          # To avoid hitting a thread limit
 version = "1.20"
 
 import sys, getopt, urlparse, time, random, socket, signal, re
-import threading, Queue, SocketServer
-import irc.client, logging
+import threading, Queue, SocketServer, select, itertools
+import logging
 try:
     import simplejson as json  # Faster, also makes us Python-2.4-compatible
 except ImportError:
@@ -88,14 +86,280 @@ except ImportError:
 # same problem - there is little point in reliable delivery to a relay
 # that is down or unreliable.
 #
-# This code uses only NICK, JOIN, PART, MODE, and PRIVMSG. It is strictly
-# compliant to RFC1459, except for the interpretation and use of the
-# DEAF and CHANLIMIT and (obsolete) MAXCHANNELS features.  CHANLIMIT
-# is as described in the Internet RFC draft
+# This code uses only NICK, JOIN, PART, MODE, PRIVMSG, USER, and QUIT. 
+# It is strictly compliant to RFC1459, except for the interpretation and
+# use of the DEAF and CHANLIMIT and (obsolete) MAXCHANNELS features.
+#
+# CHANLIMIT is as described in the Internet RFC draft
 # draft-brocklesby-irc-isupport-03 at <http://www.mirc.com/isupport.html>.
 # The ",isnick" feature is as described in
 # <http://ftp.ics.uci.edu/pub/ietf/uri/draft-mirashi-url-irc-01.txt>.
 
+# Historical note: the IRCClient and IRCServerConnection classes
+# (~270LOC) replace the overweight, overcomplicated 3KLOC mass of
+# irclib code that irker formerly used as a service library.  They
+# still look similar to parts of irclib because I contributed to that
+# code before giving up on it.
+
+class IRCError(Exception):
+    "An IRC exception"
+    pass
+
+class IRCClient():
+    "An IRC client session to one or more servers."
+    def __init__(self):
+        self.mutex = threading.RLock()
+        self.server_connections = []
+        self.event_handlers = {}
+        self.add_event_handler("ping",
+                               lambda c, e: c.ship("PONG %s" % e.target))
+
+    def newserver(self):
+        "Initialize a new server-connection object."
+        conn = IRCServerConnection(self)
+        with self.mutex:
+            self.server_connections.append(conn)
+        return conn
+
+    def spin(self, timeout=0.2):
+        "Spin processing data from connections forever."
+        # Outer loop should specifically *not* be mutex-locked.
+        # Otherwise no other thread would ever be able to change
+        # the shared state of an IRC object running this function.
+        while True:
+            with self.mutex:
+                sockets = [x.socket for x in self.server_connections if x is not None]
+                sockets = [x for x in sockets if x is not None]
+                if sockets:
+                    (insocks, _o, _e) = select.select(sockets, [], [], timeout)
+                    with self.mutex:
+                        for s, c in itertools.product(insocks, self.server_connections):
+                            if s == c.socket:
+                                c.consume()
+
+                else:
+                    time.sleep(timeout)
+
+    def add_event_handler(self, event, handler):
+        "Set a handler to be called later."
+        with self.mutex:
+            event_handlers = self.event_handlers.setdefault(event, [])
+            event_handlers.append(handler)
+
+    def handle_event(self, connection, event):
+        with self.mutex:
+            h = self.event_handlers
+            th = sorted(h.get("all_events", []) + h.get(event.type, []))
+            for handler in th:
+                handler(connection, event)
+
+    def drop_connection(self, connection):
+        with self.mutex:
+            self.server_connections.remove(connection)
+
+class LineBufferedStream():
+    "Line-buffer a read stream."
+    crlf_re = re.compile(b'\r?\n')
+
+    def __init__(self):
+        self.buffer = ''
+
+    def append(self, newbytes):
+        self.buffer += newbytes
+
+    def lines(self):
+        "Iterate over lines in the buffer."
+        lines = LineBufferedStream.crlf_re.split(self.buffer)
+        self.buffer = lines.pop()
+        return iter(lines)
+
+    def __iter__(self):
+        return self.lines()
+
+class IRCServerConnectionError(IRCError):
+    pass
+
+class IRCServerConnection():
+    command_re = re.compile("^(:(?P<prefix>[^ ]+) +)?(?P<command>[^ ]+)( *(?P<argument> .+))?")
+    # The full list of numeric-to-event mappings is in Perl's Net::IRC.
+    # We only need to ensure that if some ancient server throws numerics
+    # for the ones we actually want to catch, they're mapped.
+    codemap = {
+        "001": "welcome",
+        "005": "featurelist",
+        "432": "erroneusnickname",
+        "433": "nicknameinuse",
+        "436": "nickcollision",
+        "437": "unavailresource",
+    }
+
+    def __init__(self, master):
+        self.master = master
+        self.socket = None
+
+    def connect(self, server, port, nickname,
+                password=None, username=None, ircname=None):
+        log.debug("connect(server=%r, port=%r, nickname=%r, ...)",
+                  server, port, nickname)
+        if self.socket is not None:
+            self.disconnect("Changing servers")
+
+        self.buffer = LineBufferedStream()
+        self.event_handlers = {}
+        self.real_server_name = ""
+        self.server = server
+        self.port = port
+        self.server_address = (server, port)
+        self.nickname = nickname
+        self.username = username or nickname
+        self.ircname = ircname or nickname
+        self.password = password
+        try:
+            self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            self.socket.bind(('', 0))
+            self.socket.connect(self.server_address)
+        except socket.error as err:
+            raise IRCServerConnectionError("Couldn't connect to socket: %s" % err)
+
+        if self.password:
+            self.ship("PASS " + self.password)
+        self.nick(self.nickname)
+        self.user(self.username, self.ircname)
+        return self
+
+    def close(self):
+        # Without this thread lock, there is a window during which
+        # select() can find a closed socket, leading to an EBADF error.
+        with self.master.mutex:
+            self.disconnect("Closing object")
+            self.master.drop_connection(self)
+
+    def consume(self):
+        try:
+            incoming = self.socket.recv(16384)
+        except socket.error:
+            # Server hung up on us.
+            self.disconnect("Connection reset by peer")
+            return
+        if not incoming:
+            # Dead air also indicates a connection reset.
+            self.disconnect("Connection reset by peer")
+            return
+
+        self.buffer.append(incoming)
+
+        for line in self.buffer:
+            log.debug("FROM: %s", line)
+
+            if not line:
+                continue
+
+            prefix = None
+            command = None
+            arguments = None
+            self.handle_event(Event("every_raw_message",
+                                     self.real_server_name,
+                                     None,
+                                     [line]))
+
+            m = IRCServerConnection.command_re.match(line)
+            if m.group("prefix"):
+                prefix = m.group("prefix")
+                if not self.real_server_name:
+                    self.real_server_name = prefix
+            if m.group("command"):
+                command = m.group("command").lower()
+            if m.group("argument"):
+                a = m.group("argument").split(" :", 1)
+                arguments = a[0].split()
+                if len(a) == 2:
+                    arguments.append(a[1])
+
+            command = IRCServerConnection.codemap.get(command, command)
+            if command in ["privmsg", "notice"]:
+                target = arguments.pop(0)
+            else:
+                target = None
+
+                if command == "quit":
+                    arguments = [arguments[0]]
+                elif command == "ping":
+                    target = arguments[0]
+                else:
+                    target = arguments[0]
+                    arguments = arguments[1:]
+
+            log.debug("command: %s, source: %s, target: %s, "
+                      "arguments: %s", command, prefix, target, arguments)
+            self.handle_event(Event(command, prefix, target, arguments))
+
+    def handle_event(self, event):
+        self.master.handle_event(self, event)
+        if event.type in self.event_handlers:
+            for fn in self.event_handlers[event.type]:
+                fn(self, event)
+
+    def is_connected(self):
+        return self.socket is not None
+
+    def disconnect(self, message=""):
+        if self.socket is None:
+            return
+        self.quit(message)
+        try:
+            self.socket.shutdown(socket.SHUT_WR)
+            self.socket.close()
+        except socket.error:
+            pass
+        del self.socket
+        self.socket = None
+        self.handle_event(Event("disconnect", self.server, "", [message]))
+
+    def join(self, channel, key=""):
+        self.ship("JOIN %s%s" % (channel, (key and (" " + key))))
+
+    def mode(self, target, command):
+        self.ship("MODE %s %s" % (target, command))
+
+    def nick(self, newnick):
+        self.ship("NICK " + newnick)
+
+    def part(self, channel, message=""):
+        cmd_parts = ['PART', channel]
+        if message:
+            cmd_parts.append(message)
+        self.ship(' '.join(cmd_parts))
+
+    def privmsg(self, target, text):
+        self.ship("PRIVMSG %s :%s" % (target, text))
+
+    def quit(self, message=""):
+        # Triggers an error that forces a disconnect.
+        self.ship("QUIT" + (message and (" :" + message)))
+
+    def user(self, username, realname):
+        self.ship("USER %s 0 * :%s" % (username, realname))
+
+    def ship(self, string):
+        "Ship a command to the server, appending CR/LF"
+        try:
+            self.socket.send(string + b'\r\n')
+            log.debug("TO: %s", string)
+        except socket.error:
+            self.disconnect("Connection reset by peer.")
+
+class Event(object):
+    def __init__(self, evtype, source, target, arguments=None):
+        self.type = evtype
+        self.source = source
+        self.target = target
+        if arguments is None:
+            arguments = []
+        self.arguments = arguments
+
+def is_channel(string):
+    return string and string[0] in "#&+!"
+
 class Connection:
     def __init__(self, irkerd, servername, port):
         self.irker = irkerd
@@ -213,14 +477,14 @@ class Connection:
                 elif not self.connection:
                     # Queue is nonempty but server isn't connected.
                     with self.irker.irc.mutex:
-                        self.connection = self.irker.irc.server()
+                        self.connection = self.irker.irc.newserver()
                         self.connection.context = self
                         # Try to avoid colliding with other instances
                         self.nick_trial = random.randint(1, 990)
                         self.channels_joined = {}
                         try:
                             # This will throw
-                            # irc.client.ServerConnectionError on failure
+                            # IRCServerConnectionError on failure
                             self.connection.connect(self.servername,
                                                 self.port,
                                                 nickname=self.nickname(),
@@ -232,7 +496,7 @@ class Connection:
                             self.irker.debug(1, "XMIT_TTL bump (%s connection) at %s" % (self.servername, time.asctime()))
                             self.last_xmit = time.time()
                             self.last_ping = time.time()
-                        except irc.client.ServerConnectionError:
+                        except IRCServerConnectionError:
                             self.status = "disconnected"
                 elif self.status == "handshaking":
                     if time.time() > self.last_xmit + HANDSHAKE_TTL:
@@ -401,18 +665,18 @@ class Irker:
     "Persistent IRC multiplexer."
     def __init__(self, debuglevel=0):
         self.debuglevel = debuglevel
-        self.irc = irc.client.IRC()
-        self.irc.add_global_handler("ping", self._handle_ping)
-        self.irc.add_global_handler("welcome", self._handle_welcome)
-        self.irc.add_global_handler("erroneusnickname", self._handle_badnick)
-        self.irc.add_global_handler("nicknameinuse", self._handle_badnick)
-        self.irc.add_global_handler("nickcollision", self._handle_badnick)
-        self.irc.add_global_handler("unavailresource", self._handle_badnick)
-        self.irc.add_global_handler("featurelist", self._handle_features)
-        self.irc.add_global_handler("disconnect", self._handle_disconnect)
-        self.irc.add_global_handler("kick", self._handle_kick)
-        self.irc.add_global_handler("all_raw_messages", self._handle_all_raw_messages)
-        thread = threading.Thread(target=self.irc.process_forever)
+        self.irc = IRCClient()
+        self.irc.add_event_handler("ping", self._handle_ping)
+        self.irc.add_event_handler("welcome", self._handle_welcome)
+        self.irc.add_event_handler("erroneusnickname", self._handle_badnick)
+        self.irc.add_event_handler("nicknameinuse", self._handle_badnick)
+        self.irc.add_event_handler("nickcollision", self._handle_badnick)
+        self.irc.add_event_handler("unavailresource", self._handle_badnick)
+        self.irc.add_event_handler("featurelist", self._handle_features)
+        self.irc.add_event_handler("disconnect", self._handle_disconnect)
+        self.irc.add_event_handler("kick", self._handle_kick)
+        self.irc.add_event_handler("every_raw_message", self._handle_every_raw_message)
+        thread = threading.Thread(target=self.irc.spin)
         thread.setDaemon(True)
         self.irc._thread = thread
         thread.start()
@@ -482,7 +746,7 @@ class Irker:
         self.debug(1, "irker has been kicked from %s on %s" % (target, connection.server))
         if connection.context:
             connection.context.handle_kick(target)
-    def _handle_all_raw_messages(self, _connection, event):
+    def _handle_every_raw_message(self, _connection, event):
         "Log all messages when in watcher mode."
         if logfile:
             with open(logfile, "a") as logfp:
@@ -572,6 +836,7 @@ Options
 """)
 
 if __name__ == '__main__':
+    log = logging.getLogger(__name__)
     debuglvl = 0
     namestyle = "irker%03d"
     password = None
index 9d3907ac6d3f2cf73fdde95cdfa5accb551bce80..34561e1e08a2548a22fbffb06f85b785aaca8238 100644 (file)
@@ -154,8 +154,7 @@ discarded. </para>
 project page at <ulink
 url='http://www.catb.org/~esr/irker'>http://www.catb.org/~esr/irker</ulink>
 for updates and other resources, including an installable repository
-hook script. The implementation uses the Python IRC library by Joe
-Rosdahl and Jason R. Coombs.</para>
+hook script.</para>
 </refsect1>
 </refentry>