(λ (x) (create x) '(knowledge))

Intermission: Vaporbot

A guest post by Mio! · April 28th, 2024

Picture an online door-to-door salesman showing up unannounced with some crazy thing he claims will change your life. While you protest that you haven't got any authorial currency to merit his quality blogging service, he smiles and pencils in your name anyway. He suavely offers to sell you a bridge of brownies with the post and accepts vapourware as a payment method. You have no idea where to get real vapourware that isn't just a retro T-shirt, but the brownies are enticing and you figure you can leave that problem for future you. Hours later, with most of the brownies having been deliciously decimated, your sugar-saturated brain reboots and finally begins the hunt in the unlit basement of ~/junk for some non-existent wares to pay for the chocolatey treats.

As you trudge through the musty confines, you remember that you've been telling the salesman and owner of the fine establishment named Lambdacreate about an Internet Relay Chat (IRC) bot framework you were supposed to be reassembling for many weeks now which still hasn't materialised. You think with only a tiny dose of regret it would've had potential for repayment had it been made with Ruby, which is apparently his current poison of choice. You don't know a thing about Ruby (or maybe you do but it's not part of this story) except Jekyll was the blogger bee's knees a decade ago. But you decide with all the folly from the fermenting sugar in your guts to learn you some Ruby for great good! With your newly-acquired skill, you'll finally craft a vapourware and your quest for the salesman will be complete. True story! Except you're me and there were no brownies. Drats.

Step 0: acquire tools and basic skill

Here's how this quest runs. Step 0 of the quest chain is to learn just enough Ruby to make some mischief, and for that the first port of call is Learn X in Y Minutes. It's the Cliff's Notes of programming languages and the bane of Computer Science instructors everywhere, all syntax and little nuance. Next, request Ruby from a friendly neighbourhood package manager, e.g. apk install ruby in Alpine Linux. Most program paladins already have a code editor in their inventory (plus the kitchen sink if the editor is an emacs), but you can get one from the package manager. Check ruby -v and pin up the Ruby Standard Library and Core Reference for the corresponding version. There are other manuals on the official Ruby website and no time to read them, so this story will continue as if they didn't exist. Or you can still read them, but they're not necessary for the quest.

Step 0.5: come up with a recipe

One of the hallmarks of good vapourware besides non-existence is an appearance of utility without really being useful. This requires some understanding of how to be useful in order to avoid being the very thing that is unacceptable but would otherwise be desirable. Bots are all the rage nowadays so let's make an IRC bot that will connect to a server, join a channel and promptly do nothing.

Useless bot recipe

  1. Make a data list.
  2. Make it connect to an IRC server.
  3. Make it join a channel.
  4. Make it crash.
  5. Make it do nothing.

Right away you might already notice some of the things it won't do:

  • Authenticate to the server — that would make it useful in channels that require users to be registered and authenticated to the server to talk. Can't do that, Dave. You're welcome to go on an "Authenticate with Nickserv" side quest, but you're on your own there.

  • Disconnect properly from the server — unless you're one of those despicably perfect wizards who gets every spell right the first time, you'll find out quickly Ruby is great at producing runtime errors, which is like unexpectedly discovering someone took the last brownie from the buffet platter when you return for more and Ruby the waitress doesn't know what to do. You should totally take advantage of it for the crashing effect. All of this is a roundabout way to say disconnecting following protocol is redundant when it can already crash out.

  • Save settings to a config file — it would make the bot modularly reusable, more useful and therefore untenable. Instead the settings will be baked into the bot and if other code mages don't like the channels it joins they would have to fork the bot and pipe in their own settings. Since it's a frowned-upon practice, it'll have the additional benefit of mostly keeping other people's mitts off your bot. Which is arguably a useful feature but no one's counting.

  • Handle errors — it doesn't pretend to be great at what it does, which will be nothing. Like Ruby the waitress it'll look at you blankly or nope itself out if it can't handle whatever you're asking it to do.

With the Don'ts written off, what about the Do's?

  • Make a data list — this is a list of the pieces of information the bot will use to perform the other parts of the recipe, such as the IRC server's hostname and port.

  • Make it connect to an IRC server — the bot still has to do a few things to look plausibly legitimate. Of course, advertising it will do something without actually doing it will also be enough, but that'll be passing up an opportunity to whinge about Ruby's inconsistency. It'd help to know how the IRC protocol usually works (keyword being "usually" as some servers may respond on different timing for authentication commands), but connecting is generally the same across servers.

  • Make it join a channel — this was a tough choice. The bot could hang around a server without joining any chat rooms like a ghost, giving it more of the vapourware vibe in being practically non-existent even if people could message the bot if they knew its name. It'd also be a little sad. The aim here is serendipitously useless, not sad.

  • Make it crash — this can be spontaneous or on-demand.

  • Make it do nothing — the easiest way to accomplish this is to not add things it can do, while the contrarian's way is to make it do something without actually doing anything, or idle.

Some code mages will call this step "defining the scope of your application", which is just a fancy way to say you figured out what you're doing.

Step 1: craft vapourware

It's Lamdacreate's Craft Corner! Let's craft!

1. Make a data list.

Start a new file creatively called vaporbot.rb in your editor, and add a new code block:

class Vaporbot
  @@server = {
    "host" => "irc.libera.chat",
    "port" => 6697,
    "ssl"  => true,
    "nick" => "vaporbot",
    "user" => "vaporbot",
    "channels" => ["##devnull"],
    "do" => ["crash", "idle"],
    "mod" => "!",
    "debug" => true,
    }
end

This tells Ruby there's a new class object called Vaporbot which will be used to group together instructions (or functions) for the bot. (Classes can do more than that, but this time it's just being used as a box to hold the parts rather than spilling them all out onto the table.) The next line creates a new variable or item named @@server with a hash table, which is like a dictionary with a list of all the settings the bot needs to look up in order to complete certain tasks, such as the address and port of the server to connect to, the name with which it should introduce itself to the server, the channels to join, and the actions it can perform for users. Adding @@ in front of the variable name allows it to be read by functions or instructions that will be added inside the box.

The ssl key will be checked by the bot to decide whether to use SSL/TLS for the server connection. Most IRC servers will support both SSL and non-SSL connections, or encourage people to use SSL for improved security. For Libera Chat's servers, 6697 is the SSL port, and if ssl is set to false, then the port setting should be changed to 6667. mod, short for modifier, is the character that users add in front of an action word, e.g. "!ping" to signal to the bot. The debug setting will eventually tell the bot to print out all the messages it receives from the server to the terminal, which is helpful for spotting problems (this feature can be added because it makes building the bot easier, not making the bot itself more useful). The keys can have different names, but it helps to use descriptive words unless you want future you to be confused too.

2. Make it connect to an IRC server.

The bot has the data it needs, so let's give it some instructions. Lines prefixed with # are comments.

# Import the openssl and socket libraries.
require "openssl"
require "socket"

class Vaporbot
  @@server = {
    "host" => "irc.libera.chat",
    "port" => 6697,
    "ssl"  => true,
    "nick" => "vaporbot",
    "user" => "vaporbot",
    "channels" => ["##devnull"],
    "do" => ["crash", "idle"],
    "mod" => "!",
    "debug" => true,
    }

  # Add a new function named "init".
  def self.init(s = @@server)
    # Create a new connection socket.
    sock = TCPSocket.new(s["host"], s["port"])

    # If using SSL, turn it into a SSL socket.
    if s["ssl"]
      sock = OpenSSL::SSL::SSLSocket.new(sock)
      sock.connect
    end

    # Listen for messages from the server.
    # Keep running as long as the variable's value is not nil.
    while line = sock.gets
      # Print the message to the terminal.
      puts line
    end
    # Close the socket if the server ends the connection.
    sock.close
  end

end

# Call the function.
Vaporbot.init

In order to connect to the server, the bot has to set an endpoint or socket through which it can send and receive messages from the server. Fortunately Ruby comes with an extensive built-in library of classes that have methods to provide components like sockets so they don't need to be created from scratch. The first two lines at the top of the file asks Ruby to load the classes that provide the SSL and non-SSL sockets. Then they can be used in a new function init to connect to the server. The self keyword registers the function as belonging in the Vaporbot class box and allows it to be called outside of the class. (s = @@server) shows that the function takes one variable, represented inside the function as s. If no variable is provided to the function when it is called by name like Vaporbot.init, it will use the values from the @@server table. The first part of the init function passes the server's address and port values to the socket to connect on a local port. If it successfully contacts the server, it proceeds to a loop that runs over and over, listening for messages from the server (sock.gets) until it receives nothing, at which point the loop will stop, and the function wraps up by closing the socket, freeing up the local port again.

At this point if you tried running the script with the command ruby vaporbot.rb, the bot will knock on the server's door then stand there wordlessly while the server prompts for its name. After about thirty seconds the server gets tired of waiting and shuts the door on the bot. What should little vaporbot do to be let into the party? Introduce itself to the server:

    while line = sock.gets
      puts line
      resp =
        if line.include?("No Ident")
          "NICK #{s["nick"]}\r\nUSER #{s["user"]} 0 * #{s["user"]}\r\n"
        else ""
        end
      if resp != ""
        sock.write(resp)
      end
    end

include? is a built-in string function to check whether a text string contains another string. If the message from the server contains certain keywords like "No Ident", the bot will respond with their nick and user names. The #{} is used to insert variable values such as those from the @@server table inside an existing text string. The \r\n marks the end of each line when sent to the server using sock.write(). Now when the bot connects, the server can greet it by name after the bot flashes its name tag:

:tantalum.libera.chat NOTICE * :*** Checking Ident
:tantalum.libera.chat NOTICE * :*** Looking up your hostname...
:tantalum.libera.chat NOTICE * :*** Found your hostname: example.tld
:tantalum.libera.chat NOTICE * :*** No Ident response
NICK vaporbot
USER vaporbot 0 * vaporbot
:tantalum.libera.chat 001 vaporbot :Welcome to the Libera.Chat Internet Relay Chat Network vaporbot

3. Make it join a channel.

Little vaporbot is ushered in, and the server enthusiastically tells the bot about the number of revellers and many rooms available. Maybe you're already in one of those rooms and you want vaporbot to join you there too. The IRC command is, yep, you guessed it, JOIN #channel. The trick however is to wait until the server winds down its welcome speech, also known as the MOTD or message of the day, before having the bot send the join request, or it won't hear it above the sound of its own happy gushing. To join multiple channels, separate each channel name with a comma.

    while line = sock.gets
      puts line
      body =
        if line != nil
          if line.split(":").length >= 3; line.split(":")[2..-1].join(":").strip
          else line.strip; end
        else ""; end

      resp =
        if body.include?("No Ident")
          "NICK #{s["nick"]}\r\nUSER #{s["user"]} 0 * #{s["user"]}\r\n"

        elsif (body.start_with?("End of /MOTD") or
          body.start_with?("#{s["user"]} MODE"))
          "JOIN #{s["channels"].join(",")}"

        elsif body.start_with?("PING")
          if body.split(" ").length == 2; body.sub("PING", "PONG")
          else "PONG"; end

        else ""
        end
      if resp != ""
        sock.write(resp)
      end
    end

Here's an example output for joining a channel called ##devnull:

:tantalum.libera.chat 376 vaporbot :End of /MOTD command.
JOIN ##devnull
:vaporbot MODE vaporbot :+Ziw
:vaporbot!~vaporbot@example.tld JOIN ##devnull
:tantalum.libera.chat 353 vaporbot @ ##devnull :vaporbot @mio
:tantalum.libera.chat 366 vaporbot ##devnull :End of /NAMES list.

While vaporbot was making its way to a channel, you might've spotted a few changes to the listening loop. The first is a new body variable with text extracted from line, specifically the section after the channel name which is the message body. This is what IRC clients usually format and display to users, including messages from other users, so it's handy and slightly more reliable to check for keywords in this part of a message from the server, e.g. line.include? is updated to body.include?. The other addition is a clause looking for a PING call in the server messages. Before this, if you've kept the script running for a while, you might've seen the poor bot getting shown the door again shortly after a similar ping. The bot needs to periodically echo back a PONG in response to keep the connection active, like this:

PING :tantalum.libera.chat
PONG :tantalum.libera.chat

While it might be funny the first time, the disconnects will eventually become annoying. Adding a ping check will enable the bot to run mostly unattended.

4. Make it crash.

The bot can connect to a server and join a channel, so far so good. Now to introduce user triggers and make it do silly things on demand. For this let's add a new variable called @@action with another hash table of keys and values like @@server, but this time with functions as values.

  @@action = {
    "crash" => -> (sock) {
      sock.write("QUIT :Crashed >_<;\r\n") },
    }

The -> here denotes an anonymous function or lambda. It's basically a small function that may be used only a few times to not bother giving a name, or it might be part of another function that triggers functions dynamically such as from a user's text input. The function itself just sends a QUIT message to the server which disconnects the bot. An optional text can be displayed to other users in the channel (Crashed >_<;) when the bot leaves.

Next, the @@action variable is passed into the init function like @@server, and in the listening loop, a new check is added that looks for the trigger keywords in the do list including "crash" and "idle".

class Vaporbot
  @@server = {
    # Other keys and values here [...]
    "do" => ["crash", "idle"],
    "mod" => "!",
    }

  @@action = {
    "crash" => -> (sock) {
      sock.write("QUIT :Crashed >_<;\r\n") },
    }

  def self.init(s = @@server, action = @@action)
    # Socket connect statements here [...]

    while line = sock.gets
      # body and resp variables here [...]

      # Respond to other user requests with actions.
      if body.start_with?("#{s["mod"]}")
        s["do"].each do |act|
          if body == "#{s["mod"]}#{act}"
            action[act].call(sock)
          end
        end
      end
    end

  end

end

When someone sends the trigger !crash, the bot will look up "crash" in the action table (@@action by default) and retrieve the lambda function that sends the quit notice to the server. The call() method actually runs the lambda, passing in the sock variable for sock.write() to talk to the server.

The result from an IRC client looks like this:

<@mio> !crash
 <-- vaporbot (~vaporbot@example.tld) has quit (Quit: Crashed >_<;)

A note of caution: for a seriously serious bot, you'd want to only permit the bot admins to do this, e.g. by checking the person's user name (not nick, which is easier to impersonate) and potentially a preset password match ones provided in the server settings. However, since it's a seriously useless bot, allowing anyone to crash the bot might be funny or annoying depending on whether there are fudge brownies to be had on a given day. Which is to say, it's irrelevant.

5. Make it do nothing.

You know the phrase "much ado about nothing"? This next and final sub-quest of a sub-quest is a literal example of this. In the previous section you may recall the do list had an "idle" keyword. Let's add a real action for it:

  @@action = {
    "crash" => -> (sock, msg) {
      sock.write("QUIT :Crashed >_<;\r\n") },
    "idle" => -> (sock, msg) {
      sock.write("PRIVMSG #{msg["to"]} :\x01ACTION twiddles thumbs\x01\r\n") },
    }

Aside from the new msg argument (more on that in a bit), the main thing here is the idle lambda that sends an ACTION message for the channel, just like a user might type /me twiddles thumbs in their IRC app to emote or roleplay. The ACTION message isn't part of the original IRC protocol specs but from a Client-to-Client Protocol (CTCP) draft that many IRC servers have since added support for which flags certain messages to be displayed differently. The \x01 are delimiters to signal to the server there's a special message within the PRIVMSG message.

The bot needs to tell the server who the message text is for, e.g. a channel or another user. That's where the msg variable comes in. It's another hash table that lives inside the listen loop, updated as the message arrives from the server to extract values such as the user who spoke, the channel, do keywords if any and the message body. Below is the listening loop with a breakdown of the msg keys and values.

    while line = sock.gets
      # body and resp variables here [...]

      # If the message string includes an "!" character,
      # it is likely from a regular user/bot account or the server's own bots.
      # Otherwise ignore the line.
      msg =
        if body != "" and line.include?("!")
          recipient =
            if line.split(":")[1].split(" ").length >= 3
              line.split(":")[1].split(" ")[2]
            else ""; end
          sender = line.split(":")[1].split("!")[0]
          do_args =
            if body.split(" ").length >= 2; body.split(" ")[1..-1]
            else []; end
          to =
            # Names that start with "#" are channels.
            if recipient.start_with?("#"); recipient
            # Individual user.
            else sender; end
          { "body" => body, "do" => body.split(" ")[0], "do_args" => do_args,
            "sender" => sender, "recipient" => recipient, "to" => to }
        else { "body" => "", "do" => "", "do_args" => [],
          "sender" => "", "recipient" => "", "to" => "" }; end

      # Respond to other user requests with actions.
      # The `msg` variable is also passed to the `call()` method
      # so the functions in the `action` table can accessits keys and values.
      if body.start_with?("#{s["mod"]}")
        s["do"].each do |act|
          if body == "#{s["mod"]}#{act}"
            action[act].call(sock, msg)
          end
        end
      end
    end

In the idle lambda, msg["to"] provides the channel name where the trigger originated so the action will be shown there:

<@mio> !idle
     * vaporbot twiddles thumbs

Putting it all together

After some minor fiddling, here's the vapourware in all its 95 lines of glorious futility:

#!/usr/bin/env ruby
# Vaporbot // Useless by design.™
# (c) 2024 no rights reserved.
require "openssl"
require "socket"

class Vaporbot
  @@server = {
    "host" => "irc.libera.chat",
    "port" => 6697,
    "ssl"  => true,
    "nick" => "vaporbot",
    "user" => "vaporbot",
    "channels" => ["##devnull"],
    "do" => ["crash", "idle", "ping"],
    "mod" => "!",
    "debug" => true,
    }

  @@action = {
    "crash" => -> (sock, msg) {
      self.respond(sock, "QUIT :Crashed >_<;") },
    "idle" => -> (sock, msg) {
      self.respond(sock, "PRIVMSG #{msg["to"]} :\x01ACTION twiddles thumbs\x01") },
    "ping" => -> (sock, msg) {
      self.respond(sock, "PRIVMSG #{msg["to"]} :pong!")},
    }

  @@state = { "nicked" => false, "joined" => false }

  def self.respond(sock, str)
    sock.write("#{str}\r\n")
  end

  def self.init(s = @@server, action = @@action)
    sock = TCPSocket.new(s["host"], s["port"])
    if s["ssl"]
      sock = OpenSSL::SSL::SSLSocket.new(sock)
      sock.connect
    end
    while line = sock.gets
      body =
        if line != nil
          if line.split(":").length >= 3; line.split(":")[2..-1].join(":").strip
          else line.strip; end
        else ""; end
      msg =
        if body != "" and line.include?("!")
          recipient =
            if line.split(":")[1].split(" ").length >= 3
              line.split(":")[1].split(" ")[2]
            else ""; end
          sender = line.split(":")[1].split("!")[0]
          do_args =
            if body.split(" ").length >= 2; body.split(" ")[1..-1]
            else []; end
          to =
            if recipient.start_with?("#"); recipient
            else sender; end
          { "body" => body, "do" => body.split(" ")[0], "do_args" => do_args,
            "sender" => sender, "recipient" => recipient, "to" => to }
        else { "body" => "", "do" => "", "do_args" => [],
          "sender" => "", "recipient" => "", "to" => "" }; end
      resp =
        # Wait for ident prompt before sending self-introduction.
        if not @@state["nicked"] and body.include?("No Ident")
          @@state["nicked"] = true
          "NICK #{s["nick"]}\r\nUSER #{s["user"]} 0 * #{s["user"]}"
        # Wait for user mode set before requesting to join channels.
        elsif not @@state["joined"] and (body.start_with?("End of /MOTD") or
          body.start_with?("#{s["user"]} MODE"))
          @@state["joined"] = true
          "JOIN #{s["channels"].join(",")}"
        # Watch for server pings to keep the connection active.
        elsif body.start_with?("PING")
          if body.split(" ").length == 2; body.sub("PING", "PONG")
          else "PONG"; end
        else ""; end
      # Respond to events and print to standard output.
      if resp != ""; self.respond(sock, resp); end
      if s["debug"] and line != nil; puts line; end
      if s["debug"] and resp != ""; puts resp; end
      # Respond to other user requests with actions.
      if body.start_with?("#{s["mod"]}")
        s["do"].each do |act|
          if body == "#{s["mod"]}#{act}"; action[act].call(sock, msg); end
        end
      end
    end
    sock.close
  end
end


Vaporbot.init
  • #!/usr/bin/env ruby is a shebang that tells terminals in Unix-based OSes to find the ruby program and use it to run the file when it's called as ./vaporbot.rb instead of ruby vaporbot.rb.

  • Look, a new !ping trigger! It makes little vaporbot yell "pong!" in response! So excitement! Much wow!

  • @@state["nicked"] and "@@state["joined"] act as flags that are set the first time the bot sends its name and joins a channel, so it won't try to do either again until the next time it's restarted and connects to the server.

As long as it's neither officially released in its own package nor deployed to the designated server, it can be considered a type of vapourware. Yay for arbitrary criteria!

Step 2: detour for a hot take

Although semi-optional, hot takes and lists are common amenities found on blogs these days so here's a 2-in-1 free with a buy-in of this vapourware. This half-baked opinion arose from learning beginner's Ruby in an afternoon and delivered while it's still fresh. First impressions thirty years late sort of fresh.

Things to like about Ruby:

  • Sizeable standard library — Ruby bundles a number of modules both automatically available and by import, including ones for JSON and OpenSSL (vaporbot only briefly demo-ed one feature of the latter). My tour of a new programming language occasionally includes taking a peek into the built-in toolbox or checking whether it has a decent string module, as much of my scripting currently involves splicing and mangling text. Classes for primitive types like String and Array look fairly comprehensive. (It might be less of a factor for apps mostly manipulating custom objects where you need to write conversion methods anyway.) The minimalists might shake their heads, but having a robust standard library is super helpful for getting on with the core operations of your vapourware, instead of being distracted writing utility classes to fill in the most basic functionality, though this sometimes comes at the expense of a larger install size. Unfortunately a few handy ones like RSS are no longer part of the bundled libraries, but if you don't mind using a language's package manager like RubyGems they're just one install command away. Somewhat notably, CGI is still included.

  • Usually helpful errors — there hasn't been a whole lot of opportunities yet for this vapourware to go wrong, but syntax errors are generally clear and include the type of the variable or argument that the problem function is operating on. Programming newbies can rejoice as it underlines the faulty segment and suggests other method or constant names, with the caveat it doesn't always find a suggestion. A lot of languages do this now but there was time when some didn't, so older languages could get some credit for pioneering or modernising their error reporting.

    vaporbot.rb:50:in `init': undefined method `includes?' for an instance of String (NoMethodError)
    
            if body != "" and line.includes?("!")
                                  ^^^^^^^^^^
    Did you mean?  include?
            from vaporbot.rb:101:in `<main>'
    

Things to like less about Ruby:

  • Runtime errors — coming to Ruby after almost two years of messing around with a strongly-typed compiled language, this is arguably a major drawback of using some interpreted languages and isn't specific to Ruby. Showstopping errors from missing methods causing the bot to crash and lose connection with the server are fun in useless apps, not so much if the bot is supposed to stay connected.

    :tantalum.libera.chat NOTICE * :*** Checking Ident
    :tantalum.libera.chat NOTICE * :*** Looking up your hostname...
    :tantalum.libera.chat NOTICE * :*** Found your hostname: example.tld
    (NoMethodError)n `respond': undefined method `NICK vaporbot
    USER vaporbot 0 * vaporbot
    ' for an instance of OpenSSL::SSL::SSLSocket
    
        sock.send("#{str}\r\n")
            ^^^^^
            from vaporbot.rb:81:in `init'
            from vaporbot.rb:96:in `<main>'
    

    Maybe the error could have been caught earlier before the bot got to the server door. For the bot to only find out it can't speak when the server asks for its name is a tiny bit weird? Sorry vaporbot, your crafting buddy here didn't equip you with a working mic before sending you off to meet the server. In this instance sock.send() is a defined method in the Socket class that includes TCPSocket, but unsupported by OpenSSL SSLSocket.

    # This is for non-SSL sockets only.
    sock.send("#{str}\r\n")
    
    # One of the following works for both.
    sock << "#{str}\r\n"
    sock.write("#{str}\r\n")
    sock.puts("#{str}\r\n")
    

    The higher level of syntactic sugar is fine because it increases the chances of finding a method that works, as long as you don't forget and use another method for no apparent reason elsewhere in the code later.

  • Verbose syntax — this is squarely in nitpicking territory. Every logic block has to be terminated with end. It's vaguely reminiscent of shell scripts where semi-colons can be used to terminate lines and not putting the end delimiter on new lines can reduce the line count in longer scripts if you care a lot about that, and some people might appreciate it as a flexibility. The ending delimiter is often unneeded in space/indent-delimited languages, and lumping lines together like that makes it less readable in some cases, so it might be a minor advantage over being able to omit it entirely. The mix of camel case class names with methods in kebab case like OpenSSL::SSL::SSLContext.connect_nonblock is a mild eyesore, again only if a coder cares about styling. Methods that return a boolean get a mark ? as in include? for seemingly no special reason. Most times it should be clear from later usage if a function returns a boolean. Plus tiny things like casecmp but each_line.

Bottom line: neither awful nor exceptional — keeping in mind this vapourware crafter is partial to languages that save coders from tripping over themselves, and paying for performance/speed costs up front.

Step 3: complete quest

If you're reading this, it means the quest is complete. Achievement got!

Hopefully you've enjoyed this intermission from the regularly scheduled programming.

Will vaporbot get a phantom update that will enable it to procure more make-believe brownies from the salesman? Or will it be thwarted by its crafter buddy's sugar-induced coma? Find out in the next instalment*!

* Available through participating authors only. Offer not valid on one-shot posts. Invisible terms and conditions apply. Not coming soon to a browser near you.

Bio

(defparameter *Will_Sinatra* '((Age . 31) (Occupation . DevOps Engineer) (FOSS-Dev . true) (Locale . Maine) (Languages . ("Lisp" "Fennel" "Lua" "Go" "Nim")) (Certs . ("LFCS"))))

"Very little indeed is needed to live a happy life." - Aurelius

Software Development: