Een Minecraft Discord Bot in Rust - Deel 2 - Het Verkennen van Asynchrone Discord Bots in Rust met Behulp van Poise en Tokio

Rust tools voor een Discord Bot en ChatGPT trucjes.

Rust
Discord Bot
Tokio
Poise
Author

Mees Molenaar

Published

January 6, 2024

Intro

In de vorige post ben ik begonnen met het uitleggen van de voor- en nadelen Rust. Daarnaast heb ik besproken hoe je ChatGPT kunt gebruiken om een nieuwe programmeertaal te leren. In deze post ga ik uitleggen hoe je een Discord Bot kunt maken.

Er zijn verschillende crates om sneller projecten af te maken omdat deze werk uit handen nemen. Het plan was om voor de Discord bot Serenity en Shuttle te gaan gebruiken. Maar tijdens het maken van de bot kwam ik erachter dat deze twee crates toch niet de juiste waren.

Uiteindelijk zijn het deze crates geworden: * Poise deze crate gebruikt Serenity, dus indirect wordt dat nog wel gebruikt. * Tokio

Ik kwam bij Poise uit omdat die het maken van slash-commands eenvoudiger maakt dan Serenity. En ik stopte met het gebruik van Shuttle, nadat ik vastliep op deze fout:

Runtime error: Failed to verify the version of shuttle-runtime in /Users/meesmolenaar/Brain/Projects/minecraft-recipe-discord-bot/target/debug/minecraft-recipe-discord-bot. Is cargo targeting the correct executable?

Ik heb veel tijd gespendeerd aan het oplossen van deze fout, maar tot op heden is de oplossing nog niet gevonden. Omdat ik Shuttle niet meer gebruik, had ik wel een andere runtime nodig. Tokio heeft een veel gebruikte en stabiele asynchrone runtime en is daarom gekozen.

Het begrijpen van asynchroon programmeren in Rust met Tokio.

Rust heeft een eigen implementatie van asynchroon programmeren ten opzichte van andere programmeertalen. Bijvoorbeeld: Rust heeft geen ingebouwde asynchrone runtime 1. Daarentegen heeft Rust wel ingebouwde tools async/.await om asynchrone functies te schrijven die lijken op synchrone functies.

Wanneer je async toevoegt aan een code block of functie dan wordt die code een state machine die de Future trait implementeert. Deze code retourneert ook een Future en je moet deze code dan laten uitvoeren door een excecutor.

Binnen een async functie of codeblok kun je .await gebruiken op een andere Future. Dit instrueert het programma om te wachten tot de Future is voltooid. Een cruciaal kenmerk van .await is dat het de uitvoerende thread niet blokkeert, waardoor de runtime andere taken kan uitvoeren tijdens het wachten. Dit efficiënte gebruik van bronnen maakt Rust bijzonder geschikt voor I/O-intensieve toepassingen en taken met veel gelijktijdige operaties.

Dit voorbeeld (hieronder) demonstreert het gebruik van async/.await in Rust met de Tokio runtime. Het illustreert een belangrijk voordeel van asynchrone programmering: het gelijktijdig uitvoeren van meerdere taken. Terwijl de uitvoering van taak 1 wordt uitgesteld – bijvoorbeeld tijdens het wachten op een netwerkverzoek – kan taak 2 alvast worden uitgevoerd. Dit aspect van asynchroniteit zorgt ervoor dat je programma efficiënt blijft draaien, zelfs wanneer bepaalde operaties vertraging ondervinden.

use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
    println!("Taak starten...");
    let task1 = tokio::spawn(my_async_function());
    let task2 = tokio::spawn(another_async_function());

    // Wacht op beide taken om te voltooien
    task1.await.expect("Taak 1 faalde");
    task2.await.expect("Taak 2 faalde");
    println!("Beide taken voltooid.");
}

async fn my_async_function() {
    println!("Begin van de asynchrone taak 1.");
    sleep(Duration::from_secs(2)).await;
    println!("Einde van de asynchrone taak 1.");
}

async fn another_async_function() {
    println!("Begin van de asynchrone taak 2.");
    sleep(Duration::from_secs(1)).await;
    println!("Einde van de asynchrone taak 2.");
}

Output:

Taak starten...
Begin van de asynchrone taak 1.
Begin van de asynchrone taak 2.
Einde van de asynchrone taak 2.
Einde van de asynchrone taak 1.
Beide taken voltooid.

Het maken van Discord bots met Poise

Toen ik aan dit project begon, was ik van plan Serenity te gebruiken om een Discord Bot te maken. Dat werkte goed en ik had snel een eerste versie werkend, maar toen ik er een slash command van probeerde te maken, bleek dat minder eenvoudig dan verwacht. Gelukkig was ik niet de enige en daarom is de Poise crate gemaakt. Onder Poise wordt er nog steeds gebruik gemaakt van Serenity, maar het versimpelt het maken van slash commands enorm. Andere features die Poise heeft ingebouwd zijn:

  • flexible argument parsing: command parameters are defined with normal Rust types and parsed automatically
  • text commands: commands are agnostic over old text-based commands and slash commands
  • edit tracking: when user edits their message, automatically update bot response

Vervolgens heb je maar twee dingen nodig om met Poise een Discord Bot te maken, namelijk een Frameworken een slash-command.

Poise framework

Het Poise Framework is de manier om je bot te laten communiceren met de Discord API. En het gebruikt de Serenity client om een verbinding te maken met Discord. Daarnaast dien je hier de rest van de bot te configureren zoals welke commands je bot heeft.

Slash command

Een slash-command maak je simpelweg door deze attribute voor je functie te zetten:

#[poise::command(slash_command)]

Maar wat doet zo een attribuut?

Een Rust attribute is een bijzondere functie, vind ik. Rust attributes zijn namelijk procedural macros (proc_macro). En wat een procedural macro zo bijzonder maakt, is dat het code uitvoert tijdens het compileren om zo de code tijdens het compileren aan te passen. Niet alleen Poise gebruikt het met bovenstaande poise::command(), maar ook Tokio maakt gebruik van procedural macros, namelijk voor de main functie. Tokio heeft in de [documentatie] een (mooi) voorbeeld van wat die procedural macro doet:

#[tokio::main] 
async fn main() { 
    println!("Hello world"); 
}

Wordt:

fn main() { 
    tokio::runtime::Builder::new_multi_thread() 
    .enable_all() 
    .build() 
    .unwrap() 
    .block_on(async { 
        println!("Hello world"); 
    }
}

Hier zie je duidelijk dat een procedural macro de code direct verandert (zie onderaan de post, ter vergelijking, wat een Python decorator doet).

Alle stukjes bij elkaar

Nu alle puzzelstukjes zijn gesorteerd, kan de puzzel gelegd worden. Hieronder is een voorbeeld van een simpele, maar complete “hallo” Discord bot. Voel je vrij om de naam van de slash command aan te passen naar wat je maar wilt!

use poise::serenity_prelude as serenity;
use std::fs;

struct Data {}
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;

#[poise::command(slash_command)]
async fn hallo(ctx: Context<'_>) -> Result<(), Error> {
    ctx.say("Hallo").await?;
    Ok(())
}

#[tokio::main]
async fn main() {
    let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN");
    let intents = serenity::GatewayIntents::non_privileged();

    let framework = poise::Framework::builder()
    .token(token)
    .intents(intents)
    .options(poise::FrameworkOptions {
        commands: vec![hallo()],
        ..Default::default()
    })
    .setup(|ctx, _ready, framework| {
        Box::pin(async move {
            poise::builtins::register_globally(ctx, &framework.options().commands).await?;
        Ok(Data {})
        })
    })
    .build()
    .await
    .expect("Error creating framework");

  

    if let Err(why) = framework.start().await {
        println!("Client error: {:?}", why);
    }
}

Voordat je gaat, zijn hier nog een aantal zaken en benodigdheden om op te letten: 1. Je moet in Discord in een app maken met intents om berichten te mogen versturen en te ontvangen. Zie deze link voor meer informatie. 2. Je hebt een Discord Token nodig in je environment variabelen. In de link bij punt 1 staat deze informatie.

Nu ben je in staat om met Tokio en Poise een Discord bot te maken. Leef je uit :).

Veel plezier.

Mees

Extra informatie

Om te begrijpen hoe Rust’s async/.await onder de moterkap werkt, zie deze link. Voor meer informatie over procedural macros, zie deze link. Discord maakt gebruikt van sharding als je bot in meer dan 2500 servers gebruikt wordt, zie deze link voor meer informatie over sharding.

Python decorator en Rust attribute vergelijking

Voor mij is Python een bekendere taal en daarom gebruik ik die taal ter vergelijking. En hoewel het niet exact hetzelfde is, vind ik het handig om de Rust attribute te vergelijken met een Python decorator. Dit omdat zowel de Rust attribute als Python Decorator een bestaande functie verrijken of aanpassen. Het grote verschil is de manier waarop de functie wordt aangepast.

Een Python decorator is een “gewone” functie. Het argument van deze functie, is de functie die je decoreert. Bijvoorbeeld:

def print_info(func): 
    def wrapper(*args, **kwargs): 
        print(f"Function {func.__name__} called with arguments {args} and keyword arguments {kwargs}"
        result = func(*args, **kwargs) 
        print(f"Function returned {result}"

        return result 

    return wrapper 

@print_info 
def add(a, b): 
    return a +

add(3, 4)

De output van deze functie is:

Function add called with arguments (3, 4) and keyword arguments ()
Function returned 7

Footnotes

  1. https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html#async-in-rust-vs-other-languages↩︎