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(()) }