mod dice; mod parsing; use anyhow::bail; use dotenv::dotenv; use rand::{Rng, SeedableRng}; use serenity::all::{Command, CommandInteraction, CommandOptionType, CreateCommandOption, CreateComponent, FullEvent, Interaction, InteractionContext, MessageFlags, ResolvedOption, ResolvedValue}; use serenity::builder::{ CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateTextDisplay, }; use serenity::{async_trait, prelude::*}; use std::sync::Arc; use tracing::{debug, 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 { debug!("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 { warn!("Cannot respond to slash command: {why}"); } } } } FullEvent::Ready { data_about_bot, .. } => { 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, "repeat", "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.", )) .add_option(CreateCommandOption::new( CommandOptionType::String, "seed", "Roll with a fixed seed. This bot uses xoshiro256++.", )) .add_context(InteractionContext::Guild) .add_context(InteractionContext::BotDm) .add_context(InteractionContext::PrivateChannel), ) .await; debug!("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 repeat = options .iter() .find(|option| option.name == "repeat") .and_then(|option| { if let ResolvedValue::Integer(repeat) = option.value { Some(repeat) } 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 fixed_seed = options .iter() .find(|option| option.name == "seed") .and_then(|option| { if let ResolvedValue::String(fixed_seed) = option.value { Some(fixed_seed) } else { None } }) .and_then(|s| s.parse::().ok()); 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 = fixed_seed.unwrap_or_else(|| rand::rng().next_u64()); let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed); let average = match expression.average(&mut rng) { Ok(res) => res, Err(err) => { command .create_followup( &ctx.http, CreateInteractionResponseFollowup::new().content(format!("{}", err)), ) .await?; return Err(err); } }; let ev = (average * 10.0).round() / 10.0; let mut text_components = Vec::with_capacity((repeat + 2) as usize); if let Some(description) = description { text_components.push(CreateContainerComponent::TextDisplay( CreateTextDisplay::new(format!(">>> {description}\n")), )); } for _ in 0..repeat { let result = match expression.collect_evaluation(&mut rng) { Ok(res) => res, Err(err) => { command .create_followup( &ctx.http, CreateInteractionResponseFollowup::new().content(format!("{}", err)), ) .await?; return Err(err); } }; text_components.push(CreateContainerComponent::TextDisplay( CreateTextDisplay::new(format!("{}\n", result)), )); } if fixed_seed.is_some() { text_components.push(CreateContainerComponent::TextDisplay( CreateTextDisplay::new("***⚠️ This is not a fair roll. The result was generated based on a fixed seed and is thus fully reproducible.***".to_string()), )); } 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(()) }