Over the extended 4 July weekend I spent some time building a chatbot for Discord. I, of course, picked Rust, and wanted to record some thoughts and notes from the experience.

The Goal

The goal of the bot is to automate some community housekeeping at the iDevGames Discord Server. Particularly we want to experiment with jailing political discussion to a #politics channel so it doesn't disturb those who don't want to see that discussion. Whenever discussion arises out of that channel the bot should nudge the participants to take it to the appropriate channel.

That channel is controlled by roles - if you have the role political animal then you can see the #politics channel, otherwise it's totally hidden. The bot should accept requests for that role, both grants and revocation.

The Crates

I picked the following crates, in alphabetical order:

lazy_static, which I use in place of a heap-allocated static.

regex, which is incidentally what I like to pretend is a heap-allocated static.

serenity, a client for Discord's API. Much of this post is a thinly-disguised propaganda work for Serenity - it was really easy to work with, has a fairly convincing feature set out of the box, and was just generally a really fun tool to work with. I have some wishlist items that I hope to PR, but overall a terrific job well done.

toml, which I used for configuration parsing. TOML is simple, this wasn't a statement on anything other than it was easy to get going and didn't impede my progress.

Abstraction

The first major departure from the Serenity example code is that I opted to not use their Framework. It was rather invested in the notion that bot commands ought to start with a specific character, such as a bang or a tilde. It had an escape method which would run on any message, which seemed to me no better than simply using the ordinary, non-Framework handler.

I ended up rolling my own, which works on a command-map pattern with a slight twist. Each item in the command map is given each message and given the opportunity to decide whether it wants to act on that message. While there is not mechanism to stop a handler from performative action in that test, it does help break up the handler into different functions. I consider it a mild success in separation of concerns.

Logic

The logic is broadly separated into three distinct areas of discussion, which are a command for granting/revoking roles, the use of to_lowercase/1 to normalize messages for comparison, and using contains/2 for detecting those pesky political words.

The use of a regex is not a particular point of pride. It's a little prescriptive and I'm not sure it creates a stellar user experience. But it does work in role_wizard.rs, where it just uses capture groups to structure input text into the "arguments" of the command. Because I prefer to compile the regex as few times as possible, this is where I employed lazy_static.

A repeated thing is calling to_lowercase/1 on messages. Internally everything is lowercase, because Discord can get silly and I don't want to start punching every possible capitalization of things into my configuration file. I think that having better case insensitivity options in string operations would make this perhaps unnecessary. However, it could be just as possible that this could create many calls to to_lowercase/1 under the hood, so maybe it's best this way?

Finally, the distinct reason for this entire adventure (and why the bot exists) is really to loop through a list of words and find matching ones, then perform an action on that stimulus. This is WordWatcher. It's too early in the bot's lifecycle to say for sure whether this incredibly simplistic approach is sufficient, however, I suspect that it will be as long as the watch words are adequate.

Itches

Throughout development there were a few minor pain points that I think should have been simple. These range from things that I could submit a PR for to things that will likely take a PhD researcher some time to determine if an answer can even be made! In no particular order, these are my itches.

Getting the current (bot) user name

Getting the bot's user name is surprisingly difficult, to the point that I looked it up on Discord's site instead of the Serenity API docs to see if it was even supported. It created some bizarre code that looks like this, from AckMessageHandler's should_handle/3 method.

// don't copy-paste this, i learned a better way described below!
if let Ok(current_user) = ctx.http.get_current_user() {
    let user_id = current_user.id;
    
    if msg.mentions_user_id(user_id) {
        return true;
    }
}

On the surface now it doesn't look that bad, but determining that the functionality I wanted was on the ctx.http object was a huge leap for me! Serenity has packaged so much functionality into the thoughtfully-laid-out model structs that I honestly took ctx.http as an internal pointer to pass about on ceremony and not something that I should be looking at.

I spend perhaps a few hours on that. I'd like to submit a PR which can add a simple helper to Message itself, just a simple one that adds a mentions_me method. I know it would have saved me some time, but I'm fully willing to admit that I may be on the only person stuck on that.

Update 2020-07-18

This was a relatively easy itch to scratch, so I made a PR to add a simple mentions_me function to Message. A Serenity developer helpfully pointed out that the means I had been using is not cached! To use the cache collapses this down to a very neat one-liner (if and only if you have the cache feature enabled).

msg.mentions_user_id(ctx.cache.read().user.id)

Unpacking this a little, ctx.cache.read() is because that cache is really a CacheRwLock, which itself is a wrapper around an Arc<RwLock<Cache>>, which enables it to be passed around multiple threads.

Calling read/1 on it is just saying "I only want to read from the cache." This is rather nifty because it returns a wrapper around the cache, and doesn't lock the cache for any other readers. Because of RAII semantics, when that wrapper goes out of scope it gets dropped and that read lock goes away, so someone else may be able to write to the cache. This is a neat example of how Rust's ownership model has shepherded us into a safe-by-default idiom when dealing with concurrency!

As it so happens, this cache has a pre-cached CurrentUser, from which we fetch the id we wanted in the first place. No need for another HTTP call and possible error to handle from that!

Getting the current channel name

Getting the current channel id is trivial, but converting that to a name was surprisingly exciting. Again, from AckMessageHandler, I found it was easier to convert a list of channel names from configuration to channel ids.

if let Some(guild_lock) = msg.guild(&ctx.cache) {
    let guild = guild_lock.read();
    let deny_channel_ids: Vec<Option<ChannelId>> =
        self.deny_channels.iter()
        .map(|channel_name|
            guild.channel_id_from_name(&ctx.cache, channel_name)
        ).collect();

    if deny_channel_ids.contains(&Some(msg.channel_id)) {
        return false;
    }
}

Now, these are cached locally, so I don't feel too bad about it. But now that I write this and research again with a bit more experience I find that there's also Guild#channel_id_from_name/3. I would have liked to have seen a helper method on Channel to get the name of it. It would have saved a bit of time. While this doesn't really solve for really big professional applications that are designed for connecting to multiple Discord servers at a time, it would definitely have helped my small hobby project.

Testing

There are no tests, and of that I am not proud. Some of this is inexperience on me. Most of my testing experience is with Ruby and Java, both of which are more dynamic languages and it's fairly easy to mock out parts of a program. I would really appreciate some way to mock out parts of a program in Rust, as it's much more relevant for me to test the business logic in my handlers than it is to create some Rube Goldberg contraption that mimics Discord's official servers!

This is a sticky point that makes me silently kind of wish I had picked Ruby for this project - but Rust definitely redeems itself on deployment, so stay tuned!

Configuration parsing

Configuration parsing is super unwrappy. Look at this horrible code I wrote in main.rs.

// this code is kinda unwrappy but I think that's okay because dying in
// initialization is sorta expected on bad config, right?
fn parse_handlers(raw_toml: String) -> Vec<Box<dyn MysteriousMessageHandler>> {
    let toml = raw_toml.parse::<Value>().unwrap();
    let handlers = toml.as_table().unwrap().get("handlers").unwrap().as_array()
        .unwrap();
    ...

Now, some of this is on me for insisting on having configuration. This could have just as easily been handled as code, however, I thought it was better to have a configuration file that a community member could easily understand and submit a PR for.

By the comment I was feeling guilty about the unwraps, and rationalized that unwrapping on service start (where it's immediately obvious and not going to blow up a running service based on arbitrary user data from Discord) isn't really a big deal. In other words, it's a code smell, but it's in the kitty litter box where it belongs.

I chalk this up to some of the immaturity in Rust's error handling. There's a fair bit of churn between Anyhow and Eyre and the others, and the simple matter is that effectively modelling errors is hard. I understand that. I'm excited to see that conversation progress and for this little bit of a cobweb to get better in the future.

For now, it's just a todo in the back of my head for a lazy Saturday to beat on this code a little bit to make it less embarrassing.

Speaking of this code, why not Serde? Serde is fantastic, but I rather wanted something that I had more control over. My background is in Objective-C, where I got really good with NSCoder (if I do say so myself). The thing I really appreciated about NSCoder and friends was the control I had over the serialization and deserialization process. I could branch down a completely different path on a different serial version id if I really wanted to. It was all written down and easy to follow. I'd like to see something similar as an option in Rust, and I'd like to write it. I just need to find the time.

Ownership and Threading

Right in the header of the MysteriousMessageHandler is the constraint that implementers of this trait must be both Send and Sync.

pub trait MysteriousMessageHandler : Send + Sync {

I had hoped to avoid that requirement and instead wrap the list of handlers in Arc<RwLock<Vec<dyn MysteriousMessageHandler>>> or otherwise Vec<Arc<RwLock<dyn MysteriousMessageHandler>>> but each incantation I tried simply would not elide the non-Send non-Sync nature of the trait. I read the relevant Rust Book and Rustnomicon pages on the topic, and still couldn't make it work. I found that frustrating, and would like to correct it in the future so that future handlers can store whatever data they like. Or perhaps that's a bad decision on the surface of it, as then handlers will block?

Either way, I spend an hour or two on that little behavior which diverged from what I thought I read and what I expected. I'd like to revisit that sometime.

Deployment

The part of this process where I think Rust really shone was in deploying the bot. In the README I wrote as much, that to deploy you just take the built binary, scp it somewhere, and run it. There's no grand dependency chain from the OS to worry about, even the TLS layer is handled internally thanks to RusTLS. I think that's really cool!

Getting it set up to run as a service on my personal server was similarly really easy, I just used systemd. For all it's malignment, this part was delightfully simple compared to writing a service shell script and managing PID files by hand. It really was as simple as saying "here's an executable, make sure it's running as this user after boot."

The deployment instructions are available in the README, and they're surprisingly robust for something cobbled together in a few hours.

Conclusion

Overall I enjoyed the time spend on this little project. I created something that is useful, and I hope maintainable. Though there were a few sandtraps along the way, the proof really was in the deployment. Rust created a system dependency-free binary that idles at about a megabyte and a half of RAM use. For someone as const-sensitive as I, that's a win!

If you've got an itch to scratch in Discord that could be served by a Bot, I'd suggest giving Rust and Serenity a spin. Please make use of my notes and code to give yourself a head start, even if that's identifying dead ends you don't want to go down.

Happy coding!