nat1/src/main.rs

208 lines
7.4 KiB
Rust

mod dice;
mod parsing;
use anyhow::bail;
use dotenv::dotenv;
use rand::{Rng, SeedableRng};
use serenity::all::{
Command, CommandInteraction, CommandOptionType, CreateCommandOption, CreateComponent, FullEvent, Interaction, MessageFlags, ResolvedOption, ResolvedValue,
};
use serenity::builder::{
CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse,
CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateTextDisplay,
};
use serenity::{async_trait, prelude::*};
use std::sync::Arc;
use tracing::{info, warn};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok();
tracing_subscriber::fmt::init();
// Configure the client with your Discord bot token in the environment.
let token =
Token::from_env("DISCORD_TOKEN").expect("Expected a valid token in the environment");
// Build our client.
let mut client = Client::builder(token, GatewayIntents::empty())
.event_handler(Arc::new(Handler))
.await
.expect("Error creating client");
client.start().await?;
Ok(())
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn dispatch(&self, ctx: &Context, event: &FullEvent) {
// clippy can't decide between if it wants it collapsed, or if it wants you to use if let
// because it's a single pattern.
#[expect(clippy::collapsible_match)]
match event {
FullEvent::InteractionCreate { interaction, .. } => {
if let Interaction::Command(command) = interaction {
info!("Received command interaction: {command:#?}");
let content = match command.data.name.as_str() {
"roll" => {
roll(ctx, command)
.await
.unwrap_or_else(|e| warn!("error! {}", e));
None
}
_ => Some("not implemented :(".to_string()),
};
if let Some(content) = content {
let data = CreateInteractionResponseMessage::new().content(content);
let builder = CreateInteractionResponse::Message(data);
if let Err(why) = command.create_response(&ctx.http, builder).await {
info!("Cannot respond to slash command: {why}");
}
}
}
}
FullEvent::Ready { data_about_bot, .. } => {
info!("{} is connected!", data_about_bot.user.name);
let global_command = Command::create_global_command(
&ctx.http,
CreateCommand::new("roll")
.description("Roll some dice!")
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"formula",
"The dice formula, something like 2d20kh1+3",
).required(true))
.add_option(CreateCommandOption::new(
CommandOptionType::Integer,
"multi",
"Repeat the rolled formula this many times. Useful if you're controlling too many units!",
))
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"description",
"What is that roll for? Add some context!",
))
.add_option(CreateCommandOption::new(
CommandOptionType::Boolean,
"private",
"Keep the roll private and hidden, only for yourself.",
)),
)
.await;
info!("I created the following global slash command: {global_command:#?}");
info!("{} is connected!", data_about_bot.user.name);
}
_ => {}
}
}
}
pub async fn roll(ctx: &Context, command: &CommandInteraction) -> anyhow::Result<()> {
let options = command.data.options();
let Some(ResolvedOption {
value: ResolvedValue::String(formula),
..
}) = options.iter().find(|option| option.name == "formula")
else {
bail!("Formula was not provided.")
};
let multi = options
.iter()
.find(|option| option.name == "multi")
.and_then(|option| {
if let ResolvedValue::Integer(multi) = option.value {
Some(multi)
} else {
None
}
})
.unwrap_or(1)
.min(20);
let private = options
.iter()
.find(|option| option.name == "private")
.and_then(|option| {
if let ResolvedValue::Boolean(private) = option.value {
Some(private)
} else {
None
}
})
.unwrap_or(false);
let description = options
.iter()
.find(|option| option.name == "description")
.and_then(|option| {
if let ResolvedValue::String(description) = option.value {
Some(description)
} else {
None
}
});
let result = parsing::parse(&formula);
let expression = match result {
Ok(expression) => expression,
Err(error) => {
command
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::default()
.content(format!("Dice formula could not be parsed.\n{}", error))
.ephemeral(true),
),
)
.await?;
return Ok(());
}
};
if private {
command.defer_ephemeral(&ctx.http).await?;
} else {
command.defer(&ctx.http).await?;
}
let seed = rand::rng().next_u64();
let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed);
let ev = (expression.average(&mut rng)? * 10.0).round() / 10.0;
let mut text_components = Vec::with_capacity((multi + 2) as usize);
text_components.push(CreateContainerComponent::TextDisplay(
CreateTextDisplay::new(if let Some(description) = description {
format!("### {} rolled: *{description}*\n", command.user.mention())
} else {
format!("### {} rolled\n", command.user.mention())
}),
));
for _ in 0..multi {
text_components.push(CreateContainerComponent::TextDisplay(
CreateTextDisplay::new(format!("{}\n", expression.evaluate(&mut rng)?)),
));
}
text_components.push(CreateContainerComponent::TextDisplay(
CreateTextDisplay::new(format!("-# Expected value: {ev} | Seed: {seed}")),
));
command
.create_followup(
&ctx.http,
CreateInteractionResponseFollowup::new()
.components(vec![CreateComponent::Container(CreateContainer::new(
text_components,
))])
.ephemeral(private)
.flags(MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS),
)
.await?;
Ok(())
}