olab0tv2 retrospective
I have been meaning to write about my experience working with Zig to develop the second iteration of olab0t - my Twitch bot. This article was supposed to be done about a year ago but I completely forgot to write about it. Hopefully, I still remember most of the experience and pain points.
The original olab0t was written in C but after learning about Zig several years ago and playing around with the language I wanted to improve and "modernize" the bot. Some of the features I wanted to add were:
- Connecting to the Twitch server using SSL
- Adding a sound whenever messages were received in the chat
- Adding !playlist and !motd commands
- Adding better support for command handling
Setting up TLS
Zig makes it simple to interface with C libraries, so I originally planned to use OpenSSL and write basic bindings to connect using SSL. I eventually abandoned this idea since it felt too much like writing in C and I wanted to have a Zig-focused experience. I tried using the TLS library from Zig std but it didn't support TLS 1.3 which was required for connecting to Twitch. I ended up finding a TLS library written purely in Zig, supporting TLS 1.3, and seemingly kept up to date. To install the package I simply ran
zig fetch --save git+https://github.com/ianic/tls.zig
which pulls the package and saves it to your ZIG_GLOBAL_CACHE_DIR
(default
location is ~/.cache/zig/
) and adds the following line to your build.zig.zon
.tls = .{
.url = "git+https://github.com/ianic/tls.zig#1775eb4d27d7f2058ab569324a163f43496b0626",
.hash = "1220155274b6ddc0fcdf361e279efb66d60cb989eb71d4a01e963a061c96bb6995c9",
},
In order to access it in your project, you also need to add a few lines to the
build.zig
such as
const tls = b.dependency("tls", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("tls", tls.module("tls"));
When all was said and done, it only took me a few tries to get the connection up and running. It was my first time working with TLS and certificates but the examples on Github were enough to get me started.
Communicating over IRC
With the connection set up, I turned to reading and writing to the stream. Zig
makes it easy to read the IRC messages since they always end in \r\n
and
Readers have
readUntilDelimeterAlloc
. In hindsight, I could have probably just set up a
buffer and reused it with every read call but alloc
-ing and defer free
-ing
is just too much fun. In a similar way,
Writers have
writeAll
which means we don't have to manually keep track of how many bytes
we have sent in order to get the full message across.
With Twitch, the IRC messages come in different types - each one containing different bits of extra information depending - making it tricky to parse. I tried to be fancy and build separate structs to hold the different message types and then combined them into a tagged union.
pub const MsgType = enum {
ping,
privmsg,
user_state,
other,
};
pub const Msg = union(MsgType) {
ping: PingMsg,
privmsg: PrivMsg,
user_state: UserStateMsg,
other: bool,
};
All incoming messages would then be a Msg
but the MsgType
tag would give it
a particular set of fields and let me choose how to handle it. Identifying the
message type required parsing the messages and that was a bit of a pain. It
involved splitting of the full message into tokens using e.g.
std.mem.splitAny(u8, cur_msg[1..], " ");
and if
statements for string
comparison e.g. std.mem.eql(u8, msg_parts.command, "PING")
. Depending on the
message type this would have to be repeated with different delimiters to
extract specific fields. I only finished some of the message types and fields
that I was interested in leaving the rest for some other time.
Adding sound
To add sound to my project, it seemed like interfacing with C would be the simplest solution. I found a header only library called miniaudio that could play a sound in a few lines
ma_result result;
ma_engine engine;
result = ma_engine_init(NULL, &engine);
ma_engine_play_sound(&engine, argv[1], NULL);
In order to use miniaudio in the project, however, I had to take a few
additional steps. Being header only, miniaudio requires you to specify a single
C file where the implementation occurs - done by adding #define MINIAUDIO_IMPLEMENTATION
- and then including the header file. Rather than
spend time trying to work around this using the Zig build system, I just
created a dummy C file with the required contents and used that in my Zig
project through @cImport
. To get everything working I had to update the
build.zig
with the lines
exe.root_module.addCSourceFile(.{
.file = b.path("./src/csrc/miniaudio_impl.c"),
.flags = &.{ "-fno-sanitize=undefined", "-O2" },
});
exe.root_module.addIncludePath(b.path("./src/csrc"));
exe.linkLibC();
The compilation flag -fno-sanitize=undefined
was important because without
it, it would fail to build. By default, Zig compiles C code with strict
settings to help uncover bugs. Unfortunately, it looks like miniaudio runs into
some issues here due to some undefined behavior.
Handling commands
I tried several ways to handle commands directly through Zig but I didn't like
having to recompile the source code and restart the bot every time I added
something new. Instead, I decided to call out to a python script whenever a
command was called in the chat and pass the command and parameters as
arguments. The script would then handle the command and make sure the user was
allowed to execute the command (e.g. !set motd
should only be executed by
admin). The Zig program would capture stdout and send that as the response from
olab0t. The basic skeleton of the code is
const argv = [_][]const u8{
"python3",
"./commands/launch_command.py",
msg.user,
cmd,
it.rest(),
};
const proc = try std.process.Child.run(.{
.allocator = allocator,
.argv = &argv,
});
const full_msg = try std.fmt.allocPrint(allocator, "{s}", .{proc.stdout});
defer allocator.free(full_msg);
try twitch_client.sendPRIVMSG(allocator, msg.channel, full_msg);
Passing off the commands to python is great since I can take advantage of its large ecosystem. It makes it easy to interact with APIs to many services, utilize machine learning models or LLMs, or quickly hack together general purpose scripts. I have so many ideas for commands I want to implement.
Summary
I really enjoyed working on the upgrade to the bot. I was able to fix/improve
several issues from before and add many features that were missing. I took the
opportunity to learn some more Zig and tried implementing new programming
techniques. I still haven't found much use for comptime
so I know there is
much left to learn.
Zig has enough features, interest from the community, and can easily fallback to C libraries when necessary which makes it great for new projects. There is a bit of a learning curve but the availability of documentation and tutorials are starting to improve. The challenge of an evolving language with potential breaking changes always needs to be considered but for a hobby project with simple requirements, like mine, these limitations didn't get in the way. If you're not afraid to spend some time troubleshooting, sifting through old documentation, and scavenging for examples I recommend giving Zig a try. It has all of the features you need, some of the ones you want, and none of the ones you don't.