Zabalo

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.