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.
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.
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
- Send
NICK ${name}
to set the initial nickname. - Send
USER ${username} ${alternate1} ${alternate2} : ${realname}
to (sort of) authenticate yourself.
PS: you can use*
instead ofalternate
names. - At this time you will receive lots of text data from the server…
- Join a channel by sending
JOIN ${channel}
PS: Channels’ names start with#
character. Eg:#medium
. - Send a message to that channel by issuing
PRIVMSG ${channel} Hello!
- Since everything is text-based, messages destined to channel or directly to you are having
PRIVMSG
tag. - 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 stuffecho "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
: Readsstdin
and outputs only lines withPRIVMSG ${CHAN}
.
Which isPRIVMSG #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
: PrependsPRIVMSG ${CHAN}
to every received line. The lines received from thecut
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.
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