Celebrating 10 Years!

profile picture

How To Write IRC: Part 2

August 21, 2017 - Roundwall Software

This is a continuation of the article published yesterday: How To Write IRC: Part 1

Spike!

When I write a framework, I don't start by making a framework. First I mess around and build what you might call a "spike" to get an vague idea how things work. I was fairly familiar with the IRC protocol when I started, but not this new API from Apple. Writing something right off the bat with tests is quite difficult if you're not even very sure what you need to make your code do. After some time to play around, I had a better sense of direction and could be confident writing tests.

Walking Skeleton

Last thing I needed before I could write any tests was the skeleton of an app. You can see what I wrote in this initial commit on Github. Mostly it is Xcode's new-project template for a Mac app, with this I added two methods based on my experience with the spike. One to send data:

private func send(_ message: String) {
  print("queing message \(message)")
  task.write((message + "\r\n").data(using: .utf8)!, timeout: 0) { (error) in
    if let error = error {
      print("Failed to send: \(String(describing: error))")
    } else {
      print("Sent!")
    }
  }
 }

and one to receive data:

private func read() {
  task.readData(ofMinLength: 0, maxLength: 9999, timeout: 0) { (data, atEOF, error) in
    if atEOF {
      print("Connection's done!")
      return
    }

    guard let data = data, let message = String(data: data, encoding: .utf8) else {
      print("No data!")
      return
    }

    print(message)
    self.read()
  }
}

I knew these would change and would definitely be moved out of the view controller, but that wasn't super important right from the start. In a game of test-driven development, nothing needs to be done perfectly the first time. Everything is meant to change, and when it's time to change there will be tests to make sure the changes don't ruin expected behavior. I did include two assumptions in here based on my experience with the spike:

  1. When sending data, I knew that IRC servers expect you to mark the end of your message with a return and newline ( \r\n ), so I added it in the send method. This way I wouldn't need to remember to always add it to the end of messages I want to send.
  2. When reading the data, I know data needs to be handled as UTF8 encoded strings. Once reading is done (and I just print the message for now), I schedule another read.

Now tests!

Everything was all set to try to write some tests and make some stuff work. I decided a good place to start would be handling the incoming messages from the server. In the IRC protocol, messages from the server can mean different kinds of things: someone joined a channel, a message came across a channel, or the server itself needed to comunicate some status, etc. Some of these messages will need to be handled by our framework; for example, if the server sends the message "PING", we must immediately respond with "PONG" to prevent the server from disconnecting us. This isn't something I'd expect users to do, so I'll make the framework do it automatically.

First test: I figured an easy first test would be detecting the "PING" message from the server. There's no variation like in other possible kinds of messages, so the test is fairly simple:

func testPingMessage() {
  let input = parseServerMessage("PING")

  XCTAssertEqual(input, .ping)
}

To make this compile, I added a parseServerMessage function, it took a string and generated an enumerable. I thought enums with possible associated values would be a nice way to represent messages from the server and make them easy to act on, so I also added an enum with a single case, ping. Once this compiled, making the test pass wasn't too bad:

func parseServerMessage(_ message: String) -> IRCServerInput {
  if message == "PING" {
    return .ping
  }

  return .channelMessage(channel: "sup", message: "bro")
}

If we detect a ping, return .ping, if we don't, return some garbage for now. This made the test pass and made me smile. From here I made the next test:

func testChannelMessage() {
  let input = parseServerMessage("PRIVMSG #clearlynotarealchannel :this is so cool")

  XCTAssertEqual(input, IRCServerInput.channelMessage(channel: "clearlynotarealchannel", message: "this is so cool"))
}

Conveniently, I had the log from my spike where I connected to a server and got messages, so I had plenty of examples of what the server would send. It wasn't until typing this article after writing the test that I realized the test was invalid. A PRIVMSG from the server actually looks like this:

:sgoodwin!~sgoodwin@ip-213-127-113-249.ip.prioritytelecom.net PRIVMSG #clearlynotarealchannel :this is so cool

There's a bit before the keyword PRIVMSG because otherwise, how would you know who sent the message? Silly me. The process to making the test pass with the correct string was similar, however. I added a case to my enum to reflect this new type of message so I could get to making the test pass by modifying my parse function.

// The new parse function looks like this:
static func parseServerMessage(_ message: String) -> IRCServerInput {
  if message == "PING" {
    return .ping
  }

  if message.hasPrefix(":") {
    let firstSpaceIndex = message.index(of: " ")!
    let source = message.substring(to: firstSpaceIndex)
    let rest = message.substring(from: firstSpaceIndex).trimmingCharacters(in: .whitespacesAndNewlines)
    print(source)

    if rest.hasPrefix("PRIVMSG") {
      let remaining = rest.substring(from: rest.index(message.startIndex, offsetBy: 8))

      if remaining.hasPrefix("#") {
        let split = remaining.components(separatedBy: ":")
        let channel = split[0].trimmingCharacters(in: CharacterSet(charactersIn: " #"))
        let user = source.components(separatedBy: "!")[0].trimmingCharacters(in: CharacterSet(charactersIn: ":"))
        let message = split[1]

        return .channelMessage(channel: channel, user: user, message: message)
      }
    }
  }

  return .unknown(raw: message)
}

Sure it was a little messy looking and normally I'd cringe at so many if statements inside if statements, but that's ok! I could always decide later there's a neater way to solve this, and I would be able to make that change knowing my tests still pass and I hadn't ruined everything. I knew I would need to modify this function even further as there were a few other types of message I would need to handle. Typically, as I try to make code like this handle all the possible outcomes, I find a nicer way to solve the issue once I have more information. You'll notice I added another little case unknown because there's plenty of messages that I don't handle properly yet and I don't want to incorrectly mark them as something they're not.

Next I tried to handle server messages properly. These are direct communications from the server such as welcome messages when you first connect. The test used a line from the logs I kept while playing around before:

func testServerMessage() {
  let input = IRCServerInputParser.parseServerMessage(":tolkien.freenode.net 001 mukman :Welcome to the freenode Internet Relay Chat Network mukman")

  XCTAssertEqual(input, IRCServerInput.serverMessage(server: "tolkien.freenode.net", message: "Welcome to the freenode Internet Relay Chat Network mukman"))
}

First run of the test failed, as expected (if it didn't, then I'd be very confused and concerned). The parser incorrectly flagged the message as unknown when it should be one it knows. This required a little modification to the if statements I had previously:

static func parseServerMessage(_ message: String) -> IRCServerInput {
/* Old stuff was here */
    } else{

// This is where the new stuff is, server messages start with a ":" but don't have PRIVMSG

      let server = source.trimmingCharacters(in: CharacterSet(charactersIn: ": "))
      let message = rest.components(separatedBy: ":")[1]
      return .serverMessage(server: server, message: message)
    }
  }
       
  return .unknown(raw: message)
}

A small addition to the parsing function made the test pass. A fun part about test-driven development like this is: once you get the hang of it and start getting some tests to pass, it feels like you're on an awesome train to feature-delivery-town and it's picking up speed.

My initial plan for this IRC library was to support a very minimal set of functionality initially so that I could test building an app with the library with a friend who had some UI ideas. So far the library understood server messages, channel messages, and handled the important PING message. In order to connect to a server, join the a channel, and speak, I also needed to handle the JOIN message. This informs the client that either they or someone else has joined a specific channel. As always, the test includes a line from my spike's logs:

func testJoinMessage() {
  let input = IRCServerInputParser.parseServerMessage(":mukman!~sgoodwin@188.202.247.233 JOIN #clearlyatestchannel\r\n")

  XCTAssertEqual(input, IRCServerInput.joinMessage(user: "mukman", channel: "clearlyatestchannel"))
}

With this, a client could know someone joined a channel. I handled this the same as basically every other message, first I added the case to the IRCServerInput enum.

case joinMessage(user: String, channel: String)

Something different happens here though, the test doesn't fail, but causes the whole thing to crash.

IMAGE OF THE CRASH HERE

It looked like the parsing function wrongfully thought the JOIN message was a server message. I needed to disambiguate them. I tried adding another if statement into my mess and it makes the test fail instead of crash:

} else if rest.hasPrefix("JOIN") {
  print("This is a join message")
}

This was a good enough sign to me that we were in the right place and I could try to handle the rest of the string and assume it's definitely the JOIN message. Adding this bit to the new if statement:

let channel = rest.substring(from: rest.index(message.startIndex, offsetBy: 5))
return .joinMessage(user: source, channel: channel)

almost got the test to pass. The error message showed the issue remaining:

XCTAssertEqual failed: ("joinMessage(user: ":mukman!~sgoodwin@188.202.247.233", channel: "#clearlyatestchannel")") is not equal to ("joinMessage(user: "mukman", channel: "clearlyatestchannel")")

So was correctly identifying the JOIN message, except my test expected the joining user to be identified as mukman but the parse function thought the username was :mukman !~sgoodwin@188.202.247.233. I wasn't sure which is better to use to identify the user. The parsing function was returning a much more specific way to identify the user which could be useful if there were issues on an IRC server somewhere with multiple users having the same nickname. I decided to make it convenient and chop everything off to use only the simple bit. (It was also consistent with the way PRIVMSG is handled). Tests passed! You can see all the code up to this point at this commit.

Hooray! My minimum set of messages from the server were now identified and processed. Running my ugly demo program also showed that no messages from the server were causing crashes so far, which was good news as well. From here I could start to trying to organize things into a useful, usable thing instead of random functions scattered around a view controller and such.

The work continues in How To Write IRC: Part 3