Let’s Write IRC Bot in Bash!

IRC is a text-based real-time messaging protocol. Communication is handled by gateway servers that users connect to.

Mert Akengin
mert’s blog
Published in
6 min readJul 19, 2020

--

IRC is one of the oldest real-time messaging protocol of the Internet. It was really popular back in the ‘90s and early 2000s but these days it is mostly taken over by Slack.

Besides it has a protocol specification in IETF as RFC 1459, it also predates other proprietary applications such as ICQ and MSN Messenger. Which are mostly/largely based on or influenced by XMPP (also known as Jabber.)

Another remark is that the IRC is older than WWW.

Photo by Lenin Estrada on Unsplash

Since IRC is solely a text-based protocol, which is why we can just write a Bash script containing standard UNIX utilities (echo, cat, grep, awk, etc.) to communicate with it.

To jumpstart quickly, I recommend connecting to an IRC server via netcat and execute stuff manually to get a sense of whats going on and how simple is that.
The default port number of IRC is 6667. Which is plain-text connection…

user@host:~$ nc -v irc.freenode.net 6667
  1. Send NICK ${name} to set the initial nickname.
  2. Send USER ${username} ${alternate1} ${alternate2} : ${realname} to (sort of) authenticate yourself.
    PS: you can use * instead of alternate names.
  3. At this time you will receive lots of text data from the server…
  4. Join a channel by sending JOIN ${channel}
    PS: Channels’ names start with # character. Eg: #medium.
  5. Send a message to that channel by issuing PRIVMSG ${channel} Hello!
  6. Since everything is text-based, messages destined to channel or directly to you are having PRIVMSG tag.
  7. You can WHOIS yourself like: WHOIS ${nickname}

Parsing incoming messages

Incoming data relevant to our application structured as follows.

:mert!~pvtmert@[redacted] PRIVMSG #channel :hello!
:mert!~pvtmert@[redacted] PRIVMSG mynickname :sup bro!

I haven’t read the specification but fields are separated by a colon (:) character. I named them as META and TEXT, containing metadata and text-message respectively.

The first message containing # sign is sent to that channel, which I joined.
The other message contains no # sign is a direct-message between users. And instead of mynickname, you should see whatever you passed as a NICK command earlier.

To reply to channel messages, just extracting channel names via regexes like
#[^ ]+ is enough. Then passing that name to PRIVMSG is a way to go.

For sending direct-messages, destination user is needed. Which is the mert!~pvtmert@[redacted] part. The problem here is this is a full WHOIS or identity, you just need NICK to send a reply back.
Given {nick}!~{identity}@{hostname} structure, taking parts between the colon (:) and exclamation mark (!) is enough to pass it to PRIVMSG command.

Gluing netcat with bash

The -e or -c switches of netcat connects socket I/O with the application’s standard I/O.

When you check the embedded help page of netcat via netcat -h /netcat --help or nc -h /nc --help (depending on your distribution and netcat version) there is switches generally named -e and -c which allows user to pass a program. The program’s input (aka stdin) will be connected to the socket’s output pipe, and the program’s output (aka stdout) will be connected to the socket’s input pipe. Error output (aka stderr) will output to the regular console.

The initial “echoing parrot” script.

A basic bash script that echo‘es initial commands then filters stdin for incoming messages on joined channel, $CHAN. Extracts text message and prepends PRIVMSG ${CHAN} to each message, echo‘es them to send to socket.

#!/usr/bin/env bashNAME="epic_bot"
CHAN="#bottest"
echo "NICK ${NAME}"
echo "USER ${NAME} * * : ${NAME}"
sleep 3 # wait for server to do stuff
echo "JOIN ${CHAN}"
echo "PRIVMSG ${CHAN} :Hello World!"
grep --line-buffered "PRIVMSG ${CHAN}" \
| stdbuf -i0 -oL -eL cut -d: -f3- \
| sed -u "s/^/PRIVMSG ${CHAN} :/g" \
# run on debian with $ nc -vc ./irc.sh hello.ipn.dev 6667
  • grep : Reads stdin and outputs only lines with PRIVMSG ${CHAN}.
    Which is PRIVMSG #bottest in the above script.
  • stdbuf & cut : Takes the 3rd field with line buffering. Since both input and output is a pipe, pipes are normally buffered due to performance considerations. But we need real-time line-buffering.
    Takes fields 3rd and after because the first field is empty (anything before the first colon) and the second field is the metadata.
  • sed : Prepends PRIVMSG ${CHAN} to every received line. The lines received from the cut are only text-message lines. So sending them back to ${CHAN}. The ${CHAN} is #bottest, hence output becomes: PRIVMSG #bottest :message here.

Adding more logic, covering other types of messages

In order to see messages coming and going from/to server you can add tee /dev/stderr before and after actual logic. This will allow you to see messages on the console.

#!/usr/bin/env bashNAME="epic_bot"
CHAN="#bottest"
tee /dev/stderr | { echo "NICK ${NAME}"
echo "USER ${NAME} * * : ${NAME}"
sleep 3 # wait for server to do stuff
echo "JOIN ${CHAN}"
echo "PRIVMSG ${CHAN} :Hello World!"
grep --line-buffered "PRIVMSG ${CHAN}" \
| stdbuf -i0 -oL -eL cut -d: -f3- \
| sed -u "s/^/PRIVMSG ${CHAN} :/g" \
} | tee /dev/stderr

An extended version of this script that executes commands passed after dollar sign ($) [DANGEROUS!] can be found here at my gists.

IRC bot that executes commands passed after dollar sign (`$`). Source Code

And after bot joined channel, send some messages both to channel and to directly to bot as a DM. Invite to some other channel, then kick bot from channel. :)

:mert!~pvtmert@[redacted] KICK #bottest epic_bot :Kicked for spamming.

Since each line from the server containing what happened. And each line is prefixed with metadata, reading lines with read command pretty okay.

For each line, some matching mechanisms required to split actions to respond to them differently. The switch-case, case-in in Bash helps here.

while read line; do
case "${line}" in
*PRIVMSG*) # line contains PRIVMSG
;;
*KICK*) # line contains KICK
;;
*) # any other 'catch-all' eg. for logging
;;
esac
done

While metadata part can differ by type of message, user related messages are using same formatting:
:{source-user} COMMAND #channel [optional arguments] :Text Message

And because there is Windows / Telnet stuff, some servers using <CR><LF> to end lines. So using tr (trim) utilty to get rid of carriage-returns while splitting required parts before switch-case…

while read line; do
UNIX=$(echo "${line}" | tr -d '\r') # trim CR
META=$(echo "${UNIX}" | cut -d: -f2 ) # extract metadata
TEXT=$(echo "${UNIX}" | cut -d: -f3-) # extract text-message
CHAN=$(echo "${META}" | cut -d' ' -f3) # extract channel-name
CMD=$( echo "${META}" | cut -d' ' -f4) # extract command
WHO=$( echo "${META}" | cut -d'!' -f1) # extract user-name
case "${UNIX}" in
*" PRIVMSG ${CHAN} :"*)
echo "PRIVMSG ${CHAN} :Simon says: '${TEXT}' "
;;
*" PRIVMSG ${NAME} :"*)
echo "PRIVMSG ${WHO} :Hi ${WHO}!, nice to meet you."
;;
*" INVITE ${NAME}"*)
echo "JOIN ${TEXT}"
;;
*" KICK ${CHAN} ${NAME} "*)
echo "PRIVMSG ${WHO} :Hey! What does '${TEXT}' mean?! "
;;
esac
done

This basic script can;

  • Replies messages in a channel with “Simon says:” then appends original message.
  • Replies private-messages with “nice to meet you” message.
  • Can accept invitations to channels. Joins them automatically.
  • When getting kicked from a channel, asks the meaning of kick reason to a person who kicked the bot.

Full script:

There is a difference between busybox netcat and GNU coreutils netcat.

You can also directly run script by mounting it via -v then passing nc command right after image name like: docker run --rm -itw/data -v "$(pwd)/irc.sh:/irc.sh:ro" pvtmert/netcat -vc /irc.sh hello.ipn.dev 6667

Busybox invocation:

user@localhost:~$ docker run --rm -itw/data busybox:latest
/data # wget -o irc.sh https://gist.githubusercontent.com/pvtmert/115d178a6dd30a360eb81df02fb7a594/raw/irc.echo.sh
/data # chmod +x irc.sh
/data # nc -v {server_address} 6667 -e ./irc.sh

Debian invocation:

user@localhost:~$ docker run --rm -itw/data debian:latest
root@b97b5fff4759:/data# apt update && apt install -y curl
root@b97b5fff4759:/data# curl -Lo irc.sh https://gist.githubusercontent.com/pvtmert/115d178a6dd30a360eb81df02fb7a594/raw/irc.echo.sh
root@b97b5fff4759:/data# chmod +x irc.sh
root@b97b5fff4759:/data# nc -vc ./irc.sh {server_address} 6667

--

--

Mert Akengin
mert’s blog

Devops & Systems Engineer, #linux power user, #c dev,