champions_bot/src/main.rs

382 lines
14 KiB
Rust

//! Requires the 'framework' feature flag be enabled in your project's `Cargo.toml`.
//!
//! This can be enabled by specifying the feature in the dependency section:
//!
//! ```toml
//! [dependencies.serenity]
//! git = "https://github.com/serenity-rs/serenity.git"
//! features = ["framework", "standard_framework"]
//! ```
use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt::Write;
use std::sync::Arc;
use rand::seq::SliceRandom;
use rand::thread_rng;
use serenity::async_trait;
use serenity::builder::{CreateEmbed, CreateEmbedFooter, CreateMessage};
use serenity::cache::Cache;
use serenity::framework::standard::buckets::LimitedFor;
use serenity::framework::standard::macros::{command, group, help, hook};
use serenity::framework::standard::{
help_commands, Args, BucketBuilder, CommandGroup, CommandResult, Configuration, DispatchError,
HelpOptions, StandardFramework,
};
use serenity::gateway::ShardManager;
use serenity::http::Http;
use serenity::model::channel::Message;
use serenity::model::gateway::{GatewayIntents, Ready};
use serenity::model::id::UserId;
use serenity::prelude::*;
// A container type is created for inserting into the Client's `data`, which allows for data to be
// accessible across all events and framework commands, or anywhere else that has a copy of the
// `data` Arc.
struct ShardManagerContainer;
impl TypeMapKey for ShardManagerContainer {
type Value = Arc<ShardManager>;
}
struct CommandCounter;
impl TypeMapKey for CommandCounter {
type Value = HashMap<String, u64>;
}
struct HttpCache(Arc<Cache>, Http);
impl HttpCache {
fn as_ref(&self) -> (&Arc<Cache>, &Http) {
(&self.0, &self.1)
}
}
impl TypeMapKey for HttpCache {
type Value = HttpCache;
}
#[derive(Clone)]
struct Champion {
name: String,
url: String,
}
struct ChampionList;
impl TypeMapKey for ChampionList {
type Value = Vec<Champion>;
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, _: Context, ready: Ready) {
println!("{} is connected!", ready.user.name);
}
}
#[group]
#[commands(commands, champions)]
struct General;
// The framework provides two built-in help commands for you to use. But you can also make your own
// customized help command that forwards to the behaviour of either of them.
#[help]
// This replaces the information that a user can pass a command-name as argument to gain specific
// information about it.
#[individual_command_tip = "Hello! If you want more information about a specific command, just pass the command as argument."]
// Some arguments require a `{}` in order to replace it with contextual information.
// In this case our `{}` refers to a command's name.
#[command_not_found_text = "Could not find: `{}`."]
// Define the maximum Levenshtein-distance between a searched command-name and commands. If the
// distance is lower than or equal the set distance, it will be displayed as a suggestion.
// Setting the distance to 0 will disable suggestions.
#[max_levenshtein_distance(3)]
// When you use sub-groups, Serenity will use the `indention_prefix` to indicate how deeply an item
// is indented. The default value is "-", it will be changed to "+".
#[indention_prefix = "+"]
// On another note, you can set up the help-menu-filter-behaviour.
// Here are all possible settings shown on all possible options.
// First case is if a user lacks permissions for a command, we can hide the command.
#[lacking_permissions = "Hide"]
// If the user is nothing but lacking a certain role, we just display it.
#[lacking_role = "Nothing"]
// The last `enum`-variant is `Strike`, which ~~strikes~~ a command.
#[wrong_channel = "Strike"]
// Serenity will automatically analyse and generate a hint/tip explaining the possible cases of
// ~~strikethrough-commands~~, but only if `strikethrough_commands_tip_in_{dm, guild}` aren't
// specified. If you pass in a value, it will be displayed instead.
async fn my_help(
context: &Context,
msg: &Message,
args: Args,
help_options: &'static HelpOptions,
groups: &[&'static CommandGroup],
owners: HashSet<UserId>,
) -> CommandResult {
let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await;
Ok(())
}
#[hook]
async fn before(ctx: &Context, msg: &Message, command_name: &str) -> bool {
println!(
"Got command '{}' by user '{}'",
command_name, msg.author.name
);
// Increment the number of times this command has been run once. If the command's name does not
// exist in the counter, add a default value of 0.
let mut data = ctx.data.write().await;
let counter = data
.get_mut::<CommandCounter>()
.expect("Expected CommandCounter in TypeMap.");
let entry = counter.entry(command_name.to_string()).or_insert(0);
*entry += 1;
true // if `before` returns false, command processing doesn't happen.
}
#[hook]
async fn after(_ctx: &Context, _msg: &Message, command_name: &str, command_result: CommandResult) {
match command_result {
Ok(()) => println!("Processed command '{command_name}'"),
Err(why) => println!("Command '{command_name}' returned error {why:?}"),
}
}
#[hook]
async fn unknown_command(_ctx: &Context, _msg: &Message, unknown_command_name: &str) {
println!("Could not find command named '{unknown_command_name}'");
}
#[hook]
async fn normal_message(_ctx: &Context, msg: &Message) {
println!("Message is not a command '{}'", msg.content);
}
#[hook]
async fn delay_action(ctx: &Context, msg: &Message) {
// You may want to handle a Discord rate limit if this fails.
let _ = msg.react(ctx, '⏱').await;
}
#[hook]
async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError, _command_name: &str) {
if let DispatchError::Ratelimited(info) = error {
// We notify them only once.
if info.is_first_try {
let _ = msg
.channel_id
.say(
&ctx.http,
&format!("Try this again in {} seconds.", info.as_secs()),
)
.await;
}
}
}
// You can construct a hook without the use of a macro, too.
// This requires some boilerplate though and the following additional import.
use serenity::futures::future::BoxFuture;
use serenity::FutureExt;
fn _dispatch_error_no_macro<'fut>(
ctx: &'fut mut Context,
msg: &'fut Message,
error: DispatchError,
_command_name: &str,
) -> BoxFuture<'fut, ()> {
async move {
if let DispatchError::Ratelimited(info) = error {
if info.is_first_try {
let _ = msg
.channel_id
.say(
&ctx.http,
&format!("Try this again in {} seconds.", info.as_secs()),
)
.await;
}
};
}
.boxed()
}
#[tokio::main]
async fn main() {
let _ = dotenv::from_filename(".env");
let champions: Vec<Champion> =
serde_json::from_str::<HashMap<String, String>>(include_str!("../champions.json"))
.expect("failed to parse champions")
.into_iter()
.map(|(name, url)| Champion { name, url })
.collect();
// Configure the client with your Discord bot token in the environment.
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let http = Http::new(&token);
// We will fetch your bot's owners and id
let (owners, bot_id) = match http.get_current_application_info().await {
Ok(info) => {
let mut owners = HashSet::new();
if let Some(team) = info.team {
owners.insert(team.owner_user_id);
} else if let Some(owner) = &info.owner {
owners.insert(owner.id);
}
match http.get_current_user().await {
Ok(bot_id) => (owners, bot_id.id),
Err(why) => panic!("Could not access the bot id: {:?}", why),
}
}
Err(why) => panic!("Could not access application info: {:?}", why),
};
let framework = StandardFramework::new()
// Set a function to be called prior to each command execution. This provides the context
// of the command, the message that was received, and the full name of the command that
// will be called.
//
// Avoid using this to determine whether a specific command should be executed. Instead,
// prefer using the `#[check]` macro which gives you this functionality.
//
// **Note**: Async closures are unstable, you may use them in your application if you are
// fine using nightly Rust. If not, we need to provide the function identifiers to the
// hook-functions (before, after, normal, ...).
.before(before)
// Similar to `before`, except will be called directly _after_ command execution.
.after(after)
// Set a function that's called whenever an attempted command-call's command could not be
// found.
.unrecognised_command(unknown_command)
// Set a function that's called whenever a message is not a command.
.normal_message(normal_message)
// Set a function that's called whenever a command's execution didn't complete for one
// reason or another. For example, when a user has exceeded a rate-limit or a command can
// only be performed by the bot owner.
.on_dispatch_error(dispatch_error)
// Can't be used more than 2 times per 30 seconds, with a 5 second delay applying per
// channel. Optionally `await_ratelimits` will delay until the command can be executed
// instead of cancelling the command invocation.
.bucket(
"complicated",
BucketBuilder::default()
.limit(2)
.time_span(30)
.delay(5)
// The target each bucket will apply to.
.limit_for(LimitedFor::Channel)
// The maximum amount of command invocations that can be delayed per target.
// Setting this to 0 (default) will never await/delay commands and cancel the invocation.
.await_ratelimits(1)
// A function to call when a rate limit leads to a delay.
.delay_action(delay_action),
)
.await
// The `#[group]` macro generates `static` instances of the options set for the group.
// They're made in the pattern: `#name_GROUP` for the group instance and `#name_GROUP_OPTIONS`.
// #name is turned all uppercase
.help(&MY_HELP)
.group(&GENERAL_GROUP);
framework.configure(
Configuration::new()
.with_whitespace(true)
.on_mention(Some(bot_id))
.prefix("!")
// In this case, if "," would be first, a message would never be delimited at ", ",
// forcing you to trim your arguments if you want to avoid whitespaces at the start of
// each.
.delimiters(vec![", ", ","])
// Sets the bot's owners. These will be used for commands that are owners only.
.owners(owners),
);
// For this example to run properly, the "Presence Intent" and "Server Members Intent" options
// need to be enabled.
// These are needed so the `required_permissions` macro works on the commands that need to use
// it.
// You will need to enable these 2 options on the bot application, and possibly wait up to 5
// minutes.
let intents = GatewayIntents::all();
let mut client = Client::builder(&token, intents)
.event_handler(Handler)
.framework(framework)
.type_map_insert::<CommandCounter>(HashMap::default())
.type_map_insert::<HttpCache>(HttpCache(Arc::new(Cache::new()), Http::new(&token)))
.type_map_insert::<ChampionList>(champions)
.await
.expect("Failed to create client");
{
let mut data = client.data.write().await;
data.insert::<ShardManagerContainer>(Arc::clone(&client.shard_manager));
}
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
}
// Commands can be created via the attribute `#[command]` macro.
#[command]
// Options are passed via subsequent attributes.
// Make this command use the "complicated" bucket.
#[bucket = "complicated"]
async fn commands(ctx: &Context, msg: &Message) -> CommandResult {
let mut contents = "Commands used:\n".to_string();
let data = ctx.data.read().await;
let counter = data
.get::<CommandCounter>()
.expect("Expected CommandCounter in TypeMap.");
for (name, amount) in counter {
writeln!(contents, "- {name}: {amount}")?;
}
msg.channel_id.say(&ctx.http, &contents).await?;
Ok(())
}
// Repeats what the user passed as argument but ensures that user and role mentions are replaced
// with a safe textual alternative.
// In this example channel mentions are excluded via the `ContentSafeOptions`.
#[command]
#[description("generate n random champions")]
async fn champions(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await;
let cache = data
.get::<HttpCache>()
.expect("Expected HttpCache in TypeMap.");
let champions = data
.get::<ChampionList>()
.expect("Expected ChampionList in TypeMap.");
let mut shuffle = (0..champions.len()).collect::<Vec<usize>>();
shuffle.shuffle(&mut thread_rng());
let n = args.single::<usize>().unwrap_or(5);
let embeds = shuffle
.into_iter()
.take(n)
.map(|i| {
let champion = champions[i].clone();
CreateEmbed::new()
.thumbnail(champion.url)
.footer(CreateEmbedFooter::new(champion.name))
})
.collect();
msg.channel_id
.send_message(cache.as_ref(), CreateMessage::new().add_embeds(embeds))
.await?;
Ok(())
}