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 Framework
en 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:
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 + b
add(3, 4)
De output van deze functie is:
Footnotes
https://rust-lang.github.io/async-book/01_getting_started/02_why_async.html#async-in-rust-vs-other-languages↩︎