irclj

0.5.0-SNAPSHOT


A simple IRC library/bot framework.

dependencies

org.clojure/clojure
1.3.0



(this space intentionally left almost blank)
 

An IRC library for Clojure.

Irclj is an IRC library for Clojure. On one hand, the goal is to make it as flexible as possible and allow for any number of use cases. On the other hand, I want it to be super-easy to write IRC bots with Irclj.

Irclj takes the approach of stuffing all the information about an IRC connection in a single ref that the user will hold and pass around.

Irclj is also entirely asynchronous. Everything is based on events. You can register callbacks for things that happen on IRC, such as a PRIVMSG or a NOTICE. Things that you might expect to return info doesn't actually return anything. Most of Irclj's functions return immediately, since all they do is sent a message to IRC. The info is collected later when IRC sends the requested info to the client. It might not be entirely clear how to get certain information, but you can almost always collect the info you want just by registering a callback that handles the data.

(ns irclj.core
  (:require [clojure.string :as string]
            [irclj.parser :as parser]
            [irclj.process :as process]
            [irclj.events :as events]
            [irclj.connection :as connection]))

IRC Commands

clojure.string/join partially applied to ",".

(def ^{:private true
       :doc }
  comma-join
  (partial string/join ","))

Joins channels. A channel is either a string or a vector of string and key. Blocks until :ready? is delivered.

The IRC spec requires that servers allow for a join command to join several channels at once. We're doing some fun stuff to make sure that the keyed channels come first. They have to come first because if you were to try something like so: JOIN keyedchan,nonkeyed,anotherkeyedchan key,key2 then IRC would think that key2 is for nonkeyed and not for anotherkeyedchan.

(defn join
  [irc & channels]
  (let [[keyed regular] ((juxt filter remove) vector? channels)
        chans (concat (map first keyed) regular)
        keys (map last keyed)]
    (when @(:ready? @irc)
      (connection/write-irc-line irc "JOIN" (comma-join chans) (comma-join keys)))))

Part from channels. A channel is either a string or a vector of string and key. If a :message key is passed, then that message is used as the parting message. If this key is passed, it must be the last thing passed to this function.

(defn part
  [irc & channels-and-opts]
  (let [[channels opts] (split-with (complement keyword?) channels-and-opts)
        opts (apply hash-map opts)]
    (connection/write-irc-line irc "PART"
                               (string/join "," channels)
                               (when-let [message (:message opts)]
                                 (connection/end message)))))

Sends a PRIVMSG to a user or channel.

(defn message
  [irc target & s]
  (connection/write-irc-line irc "PRIVMSG" target
                             (connection/end (string/join " " s))))

Identify with NICKSERV. Will block until the connection is registered.

(defn identify
  [irc password]
  (when @(:ready? @irc)
    (message irc "NickServ" "IDENTIFY" password)))

Change your nickname on IRC.

(defn set-nick
  [irc nick]
  (connection/write-irc-line irc "NICK" nick))

Request or set the mode for a channel.

(defn mode
  [irc channel & [modes]]
  (connection/write-irc-line irc "MODE" channel modes))

Kick a user from a channel.

(defn kick
  [irc channel user & [message]]
  (connection/write-irc-line irc "KICK" channel user
                             (when message (connection/end message))))

Prepare and process a line from IRC.

We fire our raw-log callback for the lines we read from IRC as well.

(defn- process
  [irc line]
  (events/fire irc :raw-log :read line)
  (process/process-line (parser/parse line) irc))

Connect to IRC. Connects in another thread and returns a big fat ref of data about the connection, you, and IRC in general.

(defn connect
  [host port nick &
   {:keys [timeout real-name mode username callbacks]
    :or {real-name "irclj", mode 0
         callbacks {:raw-log events/stdout-callback}}
    :as all}]
  (let [{:keys [in] :as connection} (connection/create-connection host port)
        irc (ref {:connection connection
                  :shutdown? false
                  :nick nick
                  :real-name real-name
                  :username username
                  :callbacks callbacks
                  :init-mode mode
                  :network host
                  :ready? (promise)})]
    (.start
     (Thread.
      (fn []
        (connection/set-timeout irc timeout)
        (connection/register-connection irc)
        (loop [lines (connection/safe-line-seq in)]
          (if-let [line (first lines)]
            (do (process irc line)
                (recur (rest lines)))
            (events/fire irc :on-shutdown))))))
    irc))

Close the socket associated with an IRC connection.

(defn kill
  [irc]
  (.close (get-in @irc [:connection :socket])))
 

IRC messages -> Clojure maps.

This is a lightweight parser that takes an IRC message and generates a Clojure map of information from that message. One piece of terminology to keep in mind is 'line sequence'. In this context, it means the IRC message broken up at spaces.

(ns irclj.parser
  (:require [clojure.string :as string]))

Takes a line sequence and if the first element is a string beginning with ':', return it minus the ':'.

Prefixes are optional, so this might return nil. We'll use the result of this to know where to parse other things in the message later.

(defn extract-prefix
  [line-s]
  (when (= \: (ffirst line-s)) (string/join (rest (first line-s)))))

If a prefix is present in the message, parse it and return a map of :nick, :user, and :host.

(defn parse-prefix
  [line-s]
  (when-let [prefix (extract-prefix line-s)]
    (zipmap [:nick :user :host] (string/split prefix #"!|@"))))

Parse the parameters of a message. prefix is a true or false value.

If there is a prefix, we know we need to drop two elements from the message in order to get to the parameters. The approach we take to parsing the parameters is to make it all a string, split on the first ':' character, and then split the first half of the result on spaces and conjoin the second half to that list. The reason this is necessary is because the last parameter in an IRC message can contain spaces.

(defn parse-params
  [line-s prefix]
  (let [[single multi] (string/split
                        (string/join
                         " "
                         (if prefix
                           (drop 2 line-s)
                           (rest line-s)))
                        #":"
                        2)
        split-single (string/split single #" ")]
    (if multi
      (if (seq single)
        (conj split-single multi)
        [multi])
      split-single)))

Takes a raw message from IRC and turns it into a Clojure map. This map will contain :command, :params, :raw keys. If the message begins with a prefix, it will be parsed and :nick, :user, and :host keys will also be in the resulting map.

(defn parse
  [line]
  (let [line-s (string/split line #" ")
        prefix (parse-prefix line-s)]
    (into
     {:command (if prefix (second line-s) (first line-s))
      :params (parse-params line-s prefix)
      :raw line}
     prefix)))
 
(ns irclj.connection
  (:require [clojure.string :as string]
            [clojure.java.io :as io]
            [irclj.events :as events])
  (:import java.net.Socket
           java.io.IOException))

Writes a line to the IRC connection and fires the raw-log callback. Can take arbitrary arguments, joining them with spaces (like println).

We want to allow users to log raw input and output in whatever way they please. To that, we'll fire the :raw-log callback function when we read and write.

(defn write-irc-line
  [irc & s]
  (let [s (string/join " " s)]
    (events/fire irc :raw-log :write s)
    (binding [*out* (get-in @irc [:connection :out])]
      (println s))))

If param is nil, return nil. Otherwise, return param with : prefixed.

You usually want to prefix your last parameter with a : so that IRC knows to allow it to contain spaces and parse it all as one param. This is for that. write-irc-line could do this itself, but I think this is sufficient. If write-irc-line did it internally, it would mean doing some very expensive operations. While in our simple case it would hardly matter, this is really just as easy and more flexible. Tacking (end ..) on your last param if necessary isn't much of a chore.

(defn end
  [param]
  (when (seq param)
    (str \: param)))

Creates a socket from a host and a port. Returns a map of the socket and readers over its input and output.

(defn create-connection
  [host port]
  (let [socket (Socket. host port)]
    {:socket socket
     :in (io/reader socket)
     :out (io/writer socket)}))

writes NICK and USER messages to IRC, registering the connection.

IRC requires that you do this little dance to register your connection with the IRC network.

(defn register-connection
  [irc]
  (let [{:keys [nick username real-name init-mode]} @irc]
    (write-irc-line irc "NICK" nick)
    (write-irc-line irc "USER" (or username nick) init-mode "*" (end real-name))))

Set a timeout on the socket. timeout is in milliseconds.

If something happens with the connection that we don't otherwise notice, we want it to be able to timeout appropriately so that we can move along. This will set a timeout in milliseconds that will throw a SocketTimeoutException if no data is received during that time.

(defn set-timeout
  [irc timeout]
  (when timeout
    (.setSoTimeout (:socket (:connection @irc)) timeout)))

Get an infinite lazy sequence of lines from a reader.

BufferedReader, the reader we use, promises that reading from it if it is empty (if it is dead/closed/etc) will return nil. Unfortunately, the InputStream from Socket throws an IOException instead. Because of this, we can't use the line-seq from core and still handle dead connections gracefully. This is the same as clojure.core/line-seq but catches the IOException and returns nil.

(defn safe-line-seq
  [rdr]
  (try
    (cons (.readLine rdr) (lazy-seq (safe-line-seq rdr)))
    (catch IOException _ nil)))
 

Default callbacks and event firing.

(ns irclj.events)

Fire a callback of type type if it exists, passing irc and args.

We're using an event-based system for communicating with users. This fires event callbacks.

(defn fire
  [irc type & args]
  (when-let [callback (get-in @irc [:callbacks type])]
    (apply callback irc args)))

A raw-log callback that prints to stdout.

This is the default raw-log callback function. It logs input and output to stdout, which is the most common use case.

(defn stdout-callback
  [_ type s]
  (println
   (case type
     :write (str ">> " s)
     :read s)))
 

Processes IRC messages.

(ns irclj.process
  (:require [clojure.string :as string]
            [irclj.connection :as connection]
            [irclj.events :as events]))

Process a parsed IRC message.

We're going handle IRC messages polymorphically. Whatever IRC commands we support are implemented as process-line implementations. process-line takes the result of irclj.parser/parse.

(defmulti process-line
  (fn [m _] (:command m)))

Numeric

Parses the PREFIX section of an ISUPPORT message. Returns a map of symbols to their corresponding modes.

(defn- parse-prefix
  [{:keys [raw]}]
  (when-let [[modes prefixes] (next (re-find #"PREFIX=\((.*?)\)(\S+)" raw))]
    (zipmap prefixes modes)))

We want to parse this line to find out which modes a user can have (operator, voice, etc).

(defmethod process-line "005" [m irc]
  (when-let [prefixes (parse-prefix m)]
    (dosync (alter irc assoc :prefixes prefixes))))

IRC sends 332 to tell you what the channel topic is (if present).

(defmethod process-line "332" [{:keys [params] :as m} irc]
  (dosync (alter irc assoc-in [:channels (second params) :topic]
                 {:text (last params)}))
  (events/fire irc :332 m))

IRC sends 333 to tell you the user who last set the topic and when.

(defmethod process-line "333" [{:keys [params] :as m} irc]
  (let [[_ channel nick timestamp] params]
    (dosync
     (alter irc update-in [:channels channel :topic]
            assoc
            :nick nick
            :timestamp timestamp))))

Returns a function that parses a nick, returning a map where the nick is the key and the value is another map containing a :mode key which is either the user's mode (determined by the first character of the nick) if it is present in prefixes or nil if not.

(defn- nick-parser
  [prefixes]
  (fn [nick]
    (let [prefix (-> nick first prefixes)]
      [(if prefix (subs nick 1) nick) {:mode prefix}])))

A map of indicators from 353 to their meanings.

(def 
  indicators
  {"@" :secret
   "*" :private
   "=" :public})

353 gives you the list of users that are in a channel. We want this.

(defmethod process-line "353" [{:keys [params]} irc]
  (let [[_ indicator channel names] params
        names (into {}
                    (map (nick-parser (:prefixes @irc))
                         (string/split names #" ")))]
    
    (dosync
     (alter irc update-in [:channels channel]
            (fn [old]
              (-> old
                  (assoc :indicator (doto (indicators indicator) prn))
                  (update-in [:users] #(into names %))))))))

At this point, the IRC server has registered our connection. We can communicate this by delivering our ready? promise.

(defmethod process-line "001" [m irc]
  (deliver (:ready? @irc) true)
  (events/fire irc :001 m))

So we can keep mode lists up to date.

(defmethod process-line "324" [{:keys [params] :as m} irc]
  (let [[_ channel & modes] params]
    (dosync
     (alter irc assoc-in [:channels channel :mode] (string/join " " modes)))
    (events/fire irc :324 m)))

We can't really recover from a nick-already-in-use error. Just throw an exception.

(defmethod process-line "433" [m irc]
  (events/fire irc :433 m)
  (throw (Exception. "Nick is already taken. Can't recover.")))

Wordy Responses

PONG!

(defmethod process-line "PING" [m irc]
  (connection/write-irc-line irc (.replace (:raw m) "PING" "PONG")))
(defn- update-nicks [users old-nick new-nick]  
  (when users
    (let [old-data (users old-nick)]
      (assoc (dissoc users old-nick) new-nick old-data))))
(defn- update-channels [channels old-nick new-nick]
  (into {}
        (for [[channel data] channels]
          [channel
           (update-in data [:users] update-nicks old-nick new-nick)])))

We need to process this so that we can reflect NICK changes. This is a fairly complicated process. NICK messages give you no information at all about what channels the user changing their nick is in. This is understandable, but it means we have to work our asses off a bit. This gnarly code is necessary because we need to update the user list in each channel. Since we don't know which channels, we have to look at all of them.

(defmethod process-line "NICK" [{:keys [nick params] :as m} irc]
  (let [new-nick (first params)]
    (dosync
     (alter irc
            (fn [old]
              (let [old (if (= (:nick @irc) nick)
                          (assoc old :nick new-nick)
                          old)]
                (update-in old [:channels] update-channels nick new-nick))))))
  (events/fire irc :nick m))
(defmethod process-line "JOIN" [{:keys [nick params] :as m} irc]
  (dosync
   (alter irc assoc-in [:channels (first params) :users nick] nil))
  (events/fire irc :join m))
(defmethod process-line "PART" [{:keys [nick params] :as m} irc]
  (dosync
   (alter irc update-in [:channels (first params) :users] dissoc nick))
  (events/fire irc :part m))

Modes are complicated. Parsing them and trying to update a bunch of data properly would be error-prone and pointless. Instead, we'll just let clients do that if they really want to. However, we will go ahead and request the MODE from IRC when we see that it has been changed, that way we can maintain the current channel modes.

(defmethod process-line "MODE" [m irc]
  (connection/write-irc-line irc "MODE" (first (:params m)))
  (events/fire irc :mode m))
(defmethod process-line "KICK" [{:keys [params] :as m} irc]
  (dosync
   (alter irc update-in [:channels (first params) :users]
          dissoc (second params)))
  (events/fire irc :kick m))

We obviously don't need a defmethod for every single protocol response, so we'll automagically fire callbacks for any response we don't recognize.

(defmethod process-line :default [m irc]
  (events/fire irc (-> m :command string/lower-case keyword) m))