use std::fmt::Write as FmtWrite; mod dice; mod limits; mod parsing; use crate::dice::{DiscordMd, Witness}; use anyhow::{Context as AnyCtx, bail}; use dotenv::dotenv; use limits::*; 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, 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<()> { pub async fn inner(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) .clamp(1, MAX_ROLL_REPEATS as i64) as usize; 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 } }) .map(|s| s.parse::().context("Seed could not be parsed. Make sure it is a 64 bit integer between 0 and 2^64-1.")) .transpose()?; let expression = parsing::parse(formula).context("Dice formula could not be parsed.")?; let seed = fixed_seed.unwrap_or_else(|| rand::rng().next_u64()); let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed); let average = expression.average(&mut rng)?; let ev = (average * 10.0).round() / 10.0; let mut pre_paragraphs = Vec::new(); if let Some(description) = description { pre_paragraphs.insert(0, format!(">>> {description}\n")); } let mut post_paragraphs = Vec::new(); if fixed_seed.is_some() { post_paragraphs.push("***⚠️ This is not a fair roll. The result was generated based on a fixed seed and is thus fully reproducible.***".to_string()); } post_paragraphs.push(format!("-# Expected value: {ev} | Seed: {seed}")); let surrounding_par_count = pre_paragraphs.len() + post_paragraphs.len(); let roll_paragraphs = if repeat <= 40 - surrounding_par_count { let discord_md = Arc::new(std::sync::RwLock::new(DiscordMd { buffers: Vec::with_capacity(repeat), dice_written: 0, })); for _ in 0..repeat { let mut witness = DiscordMd::create_witness(discord_md.clone()); let result = expression.evaluate(&mut rng, &mut witness, i64::MAX)?; witness.witness_total_result(result)?; witness.end(); } let discord_md = Arc::into_inner(discord_md).unwrap().into_inner()?; discord_md.buffers } else { let mut par = String::with_capacity(8 * repeat); for i in 0..repeat { if i > 0 { write!(par, ", ")?; } write!( par, "**{}**", expression.evaluate(&mut rng, &mut std::io::sink(), i64::MAX)? )?; } vec![par] }; let paragraphs = { pre_paragraphs.extend(roll_paragraphs); pre_paragraphs.extend(post_paragraphs); pre_paragraphs }; if paragraphs.iter().map(|s| s.chars().count()).sum::() > 4000 || paragraphs.len() > 40 { bail!("Resulting message was too long, please roll fewer dice!"); }; let text_components = paragraphs .into_iter() .map(|text| CreateContainerComponent::TextDisplay(CreateTextDisplay::new(text))) .collect::>(); let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .components(vec![CreateComponent::Container(CreateContainer::new( text_components, ))]) // flags needs to be called before ephemeral for correct ordering .flags(MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS) .ephemeral(private), ); debug!("Returning response: {response:#?}"); command.create_response(&ctx.http, response).await?; Ok(()) } match inner(ctx, command).await { Ok(_) => {} Err(err) => { warn!("Error encountered: {err:?}"); command .create_response( &ctx.http, CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .components(vec![CreateComponent::Container(CreateContainer::new( vec![CreateContainerComponent::TextDisplay( CreateTextDisplay::new(err.to_string()), )], ))]) // flags needs to be called before ephemeral for correct ordering .flags( MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS, ) .ephemeral(true), ), ) .await? } }; Ok(()) }