diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c9866c1b998..2505f5218364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: include: - name: MSRV - toolchain: 1.60.0 + toolchain: 1.63.0 # don't do doctests because they rely on new features for brevity command: cargo test --all-features --lib --tests diff --git a/Cargo.lock b/Cargo.lock index 32e2440cdf1f..13cf901de054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,17 +53,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -82,6 +71,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "block-buffer" version = "0.10.3" @@ -314,17 +309,38 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" dependencies = [ - "atty", "humantime", + "is-terminal", "log", "regex", "termcolor", ] +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "flate2" version = "1.0.25" @@ -514,6 +530,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + [[package]] name = "http" version = "0.2.8" @@ -666,6 +688,17 @@ version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f88c5561171189e69df9d98bcf18fd5f9558300f7ea7b801eb8a0fd748bd8745" +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi 0.3.2", + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "itoa" version = "1.0.4" @@ -683,9 +716,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.138" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "link-cplusplus" @@ -696,6 +729,12 @@ dependencies = [ "cc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + [[package]] name = "lock_api" version = "0.4.9" @@ -755,7 +794,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.42.0", ] [[package]] @@ -783,7 +822,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] @@ -822,7 +861,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-sys 0.42.0", ] [[package]] @@ -936,7 +975,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1018,6 +1057,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rustls" version = "0.20.7" @@ -1141,7 +1193,7 @@ dependencies = [ "async-trait", "async-tungstenite", "base64", - "bitflags", + "bitflags 1.3.2", "bytes", "cfg-if", "chrono", @@ -1305,9 +1357,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.23.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" +checksum = "d78819ab9e79e14aefb149593f15c07239d0cea924cb44e1259e1bec643619e6" dependencies = [ "autocfg", "bytes", @@ -1318,7 +1370,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.42.0", ] [[package]] @@ -1676,13 +1728,37 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.0", + "windows_aarch64_msvc 0.42.0", + "windows_i686_gnu 0.42.0", + "windows_i686_msvc 0.42.0", + "windows_x86_64_gnu 0.42.0", + "windows_x86_64_gnullvm 0.42.0", + "windows_x86_64_msvc 0.42.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", ] [[package]] @@ -1691,42 +1767,84 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + [[package]] name = "windows_aarch64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + [[package]] name = "windows_i686_gnu" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + [[package]] name = "windows_i686_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + [[package]] name = "windows_x86_64_gnu" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + [[package]] name = "windows_x86_64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 5ff4a12026b1..4e91494bdbcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,13 +3,13 @@ authors = ["kangalio "] edition = "2018" name = "poise" version = "0.5.5" -rust-version = "1.60.0" +rust-version = "1.63.0" description = "A Discord bot framework for serenity" license = "MIT" repository = "https://github.com/serenity-rs/poise/" [dependencies] -tokio = { version = "1.21.1", default-features = false } # for async in general +tokio = { version = "1.25.1", default-features = false } # for async in general futures-core = { version = "0.3.13", default-features = false } # for async in general futures-util = { version = "0.3.13", default-features = false } # for async in general once_cell = { version = "1.7.2", default-features = false, features = ["std"] } # to store and set user data @@ -27,9 +27,9 @@ version = "0.11.6" [dev-dependencies] # For the examples -tokio = { version = "1.21.1", features = ["rt-multi-thread"] } +tokio = { version = "1.25.1", features = ["rt-multi-thread"] } futures = { version = "0.3.13", default-features = false } -env_logger = "0.9.0" +env_logger = "0.10.0" fluent = "0.16.0" intl-memoizer = "0.5.1" fluent-syntax = "0.11" diff --git a/README.md b/README.md index d5b5b8c8e351..0008d963dad8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Docs](https://img.shields.io/badge/docs-online-informational)](https://docs.rs/poise/) [![Docs (git)](https://img.shields.io/badge/docs%20%28git%29-online-informational)](https://serenity-rs.github.io/poise/) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Rust: 1.60+](https://img.shields.io/badge/rust-1.60+-93450a)](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html) +[![Rust: 1.63+](https://img.shields.io/badge/rust-1.63+-93450a)](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html) # Poise Poise is an opinionated Discord bot framework with a few distinctive features: diff --git a/examples/advanced_cooldowns/main.rs b/examples/advanced_cooldowns/main.rs new file mode 100644 index 000000000000..b78ecdcb30de --- /dev/null +++ b/examples/advanced_cooldowns/main.rs @@ -0,0 +1,51 @@ +use poise::serenity_prelude as serenity; + +struct Data {} // User data, which is stored and accessible in all command invocations +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; + +#[poise::command(slash_command, prefix_command)] +async fn dynamic_cooldowns(ctx: Context<'_>) -> Result<(), Error> { + { + let mut cooldown_tracker = ctx.command().cooldowns.lock().unwrap(); + + // You can change the cooldown duration depending on the message author, for example + let mut cooldown_durations = poise::CooldownConfig::default(); + if ctx.author().id.0 == 472029906943868929 { + cooldown_durations.user = Some(std::time::Duration::from_secs(10)); + } + let cooldown_ctx = ctx.cooldown_context(); + + match cooldown_tracker.remaining_cooldown(cooldown_ctx.clone(), &cooldown_durations) { + Some(remaining) => { + return Err(format!("Please wait {} seconds", remaining.as_secs()).into()) + } + None => cooldown_tracker.start_cooldown(cooldown_ctx), + } + }; + + ctx.say("You successfully invoked the command!").await?; + Ok(()) +} + +#[tokio::main] +async fn main() { + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: vec![dynamic_cooldowns()], + // This is important! Or else, the command will be marked as invoked before our custom + // cooldown code has run - even if the command ends up not running! + manual_cooldowns: true, + ..Default::default() + }) + .token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN")) + .intents(serenity::GatewayIntents::non_privileged()) + .setup(|ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }); + + framework.run().await.unwrap(); +} diff --git a/examples/feature_showcase/checks.rs b/examples/feature_showcase/checks.rs index 77dc5bf84096..382abf192edb 100644 --- a/examples/feature_showcase/checks.rs +++ b/examples/feature_showcase/checks.rs @@ -103,6 +103,26 @@ pub async fn cooldowns(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } +/// Overrides the user cooldown for a specific user +async fn dynamic_cooldown_check(ctx: Context<'_>) -> Result { + let mut cooldown_durations = ctx.command().cooldown_config.lock().unwrap(); + + // You can change the cooldown duration depending on the message author, for example + if ctx.author().id.0 == 472029906943868929 { + cooldown_durations.user = Some(std::time::Duration::from_secs(10)); + } else { + cooldown_durations.user = None + } + + Ok(true) +} + +#[poise::command(prefix_command, slash_command, check = "dynamic_cooldown_check")] +pub async fn dynamic_cooldowns(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You successfully called the command").await?; + Ok(()) +} + #[poise::command(prefix_command, slash_command)] pub async fn minmax( ctx: Context<'_>, diff --git a/examples/feature_showcase/main.rs b/examples/feature_showcase/main.rs index ba375817492c..4f9f2efb35ac 100644 --- a/examples/feature_showcase/main.rs +++ b/examples/feature_showcase/main.rs @@ -15,6 +15,7 @@ mod panic_handler; mod parameter_attributes; mod raw_identifiers; mod response_with_reply; +mod subcommand_required; mod subcommands; mod track_edits; @@ -42,6 +43,7 @@ async fn main() { checks::delete(), checks::ferrisparty(), checks::cooldowns(), + checks::dynamic_cooldowns(), checks::minmax(), checks::get_guild_name(), checks::only_in_dms(), @@ -55,6 +57,7 @@ async fn main() { inherit_checks::parent_checks(), localization::welcome(), modal::modal(), + modal::component_modal(), paginate::paginate(), panic_handler::div(), parameter_attributes::addmultiple(), @@ -65,6 +68,7 @@ async fn main() { // raw_identifiers::r#move(), // Currently doesn't work (issue #170) response_with_reply::reply(), subcommands::parent(), + subcommand_required::parent_subcommand_required(), track_edits::test_reuse_response(), track_edits::add(), ], diff --git a/examples/feature_showcase/modal.rs b/examples/feature_showcase/modal.rs index 22108502d4fa..d5a133ca7f7d 100644 --- a/examples/feature_showcase/modal.rs +++ b/examples/feature_showcase/modal.rs @@ -15,3 +15,36 @@ pub async fn modal(ctx: poise::ApplicationContext<'_, Data, Error>) -> Result<() Ok(()) } + +/// Tests the Modal trait with component interactions. +/// +/// Should be both prefix and slash to make sure it works without any slash command interaction +/// present. +#[poise::command(prefix_command, slash_command)] +pub async fn component_modal(ctx: crate::Context<'_>) -> Result<(), Error> { + ctx.send(|m| { + m.content("Click the button below to open the modal") + .components(|c| { + c.create_action_row(|a| { + a.create_button(|b| { + b.custom_id("open_modal") + .label("Open modal") + .style(poise::serenity_prelude::ButtonStyle::Success) + }) + }) + }) + }) + .await?; + + while let Some(mci) = + poise::serenity_prelude::CollectComponentInteraction::new(ctx.serenity_context()) + .timeout(std::time::Duration::from_secs(120)) + .filter(move |mci| mci.data.custom_id == "open_modal") + .await + { + let data = + poise::execute_modal_on_component_interaction::(ctx, mci, None, None).await?; + println!("Got data: {:?}", data); + } + Ok(()) +} diff --git a/examples/feature_showcase/subcommand_required.rs b/examples/feature_showcase/subcommand_required.rs new file mode 100644 index 000000000000..20cb5ffe28c1 --- /dev/null +++ b/examples/feature_showcase/subcommand_required.rs @@ -0,0 +1,34 @@ +use crate::{Context, Error}; + +/// A command with two subcommands: `child1` and `child2` +/// +/// Running this function directly, without any subcommand, is only supported in prefix commands. +/// Discord doesn't permit invoking the root command of a slash command if it has subcommands. +/// This command can be invoked only with `parent child1` and `parent child2`, due to `subcommand_required` parameter. +/// If you want to allow `parent` to be invoked without subcommand, remove `subcommand_required` parameter +#[poise::command( + prefix_command, + slash_command, + subcommands("child1", "child2"), + subcommand_required +)] +// Omit 'ctx' parameter here. It is not needed, because this function will never be called. +// TODO: Add a way to remove 'ctx' parameter, when `subcommand_required` is set +pub async fn parent_subcommand_required(_: Context<'_>) -> Result<(), Error> { + // This will never be called, because `subcommand_required` parameter is set + Ok(()) +} + +/// A subcommand of `parent` +#[poise::command(prefix_command, slash_command)] +pub async fn child1(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You invoked the first child command!").await?; + Ok(()) +} + +/// Another subcommand of `parent` +#[poise::command(prefix_command, slash_command)] +pub async fn child2(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You invoked the second child command!").await?; + Ok(()) +} diff --git a/macros/src/command/mod.rs b/macros/src/command/mod.rs index b50cf702badd..9d6655f66325 100644 --- a/macros/src/command/mod.rs +++ b/macros/src/command/mod.rs @@ -18,6 +18,7 @@ pub struct CommandArgs { // if it's actually irrational, the inconsistency should be fixed) subcommands: crate::util::List, aliases: crate::util::List, + subcommand_required: bool, invoke_on_edit: bool, reuse_response: bool, track_deletion: bool, @@ -146,6 +147,18 @@ pub fn command( return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); } + // If subcommand_required is set to true, then the command cannot have any arguments + if args.subcommand_required && function.sig.inputs.len() > 1 { + let err_msg = "subcommand_required is set to true, but the command has arguments"; + return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); + } + + // If subcommand_required is set to true, then the command must have at least one subcommand + if args.subcommand_required && args.subcommands.0.is_empty() { + let err_msg = "subcommand_required is set to true, but the command has no subcommands"; + return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); + } + // Collect argument names/types/attributes to insert into generated function let mut parameters = Vec::new(); for command_param in function.sig.inputs.iter_mut().skip(1) { @@ -261,6 +274,7 @@ fn generate_command(mut inv: Invocation) -> Result Result Result( eprintln!("An error occured in a command: {}", error); ctx.say(error).await?; } + crate::FrameworkError::SubcommandRequired { ctx } => { + let subcommands = ctx + .command() + .subcommands + .iter() + .map(|s| &*s.name) + .collect::>(); + let response = format!( + "You must specify one of the following subcommands: {}", + subcommands.join(", ") + ); + ctx.send(|b| b.content(response).ephemeral(true)).await?; + } crate::FrameworkError::CommandPanic { ctx, payload: _ } => { // Not showing the payload to the user because it may contain sensitive info ctx.send(|b| { diff --git a/src/cooldown.rs b/src/cooldown.rs index e9e54fa00880..34a01d54d9e5 100644 --- a/src/cooldown.rs +++ b/src/cooldown.rs @@ -34,15 +34,12 @@ pub struct CooldownConfig { pub __non_exhaustive: (), } -/// Handles cooldowns for a single command +/// Tracks all types of cooldowns for a single command /// /// You probably don't need to use this directly. `#[poise::command]` automatically generates a /// cooldown handler. #[derive(Default, Clone, Debug, PartialEq, Eq)] -pub struct Cooldowns { - /// Stores the cooldown durations - cooldown: CooldownConfig, - +pub struct CooldownTracker { /// Stores the timestamp of the last global invocation global_invocation: Option, /// Stores the timestamps of the last invocation per user @@ -55,12 +52,13 @@ pub struct Cooldowns { member_invocations: HashMap<(serenity::UserId, serenity::GuildId), Instant>, } -impl Cooldowns { - /// Create a new cooldown handler with the given cooldown durations - pub fn new(config: CooldownConfig) -> Self { - Self { - cooldown: config, +/// **Renamed to [`CooldownTracker`]** +pub use CooldownTracker as Cooldowns; +impl CooldownTracker { + /// Create a new cooldown tracker + pub fn new() -> Self { + Self { global_invocation: None, user_invocations: HashMap::new(), guild_invocations: HashMap::new(), @@ -71,26 +69,30 @@ impl Cooldowns { /// Queries the cooldown buckets and checks if all cooldowns have expired and command /// execution may proceed. If not, Some is returned with the remaining cooldown - pub fn remaining_cooldown(&self, ctx: CooldownContext) -> Option { + pub fn remaining_cooldown( + &self, + ctx: CooldownContext, + cooldown_durations: &CooldownConfig, + ) -> Option { let mut cooldown_data = vec![ - (self.cooldown.global, self.global_invocation), + (cooldown_durations.global, self.global_invocation), ( - self.cooldown.user, + cooldown_durations.user, self.user_invocations.get(&ctx.user_id).copied(), ), ( - self.cooldown.channel, + cooldown_durations.channel, self.channel_invocations.get(&ctx.channel_id).copied(), ), ]; if let Some(guild_id) = ctx.guild_id { cooldown_data.push(( - self.cooldown.guild, + cooldown_durations.guild, self.guild_invocations.get(&guild_id).copied(), )); cooldown_data.push(( - self.cooldown.member, + cooldown_durations.member, self.member_invocations .get(&(ctx.user_id, guild_id)) .copied(), diff --git a/src/dispatch/common.rs b/src/dispatch/common.rs index 7cac1f923b1d..7590de60d355 100644 --- a/src/dispatch/common.rs +++ b/src/dispatch/common.rs @@ -158,11 +158,12 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( } if !ctx.framework().options().manual_cooldowns { - let cooldowns = &cmd.cooldowns; - let remaining_cooldown = cooldowns + let cooldown_tracker = &cmd.cooldowns; + let cooldown_config = cmd.cooldown_config.lock().unwrap(); + let remaining_cooldown = cooldown_tracker .lock() .unwrap() - .remaining_cooldown(ctx.cooldown_context()); + .remaining_cooldown(ctx.cooldown_context(), &cooldown_config); if let Some(remaining_cooldown) = remaining_cooldown { return Err(crate::FrameworkError::CooldownHit { ctx, diff --git a/src/dispatch/prefix.rs b/src/dispatch/prefix.rs index 4bb1c010e7db..38ad04153d75 100644 --- a/src/dispatch/prefix.rs +++ b/src/dispatch/prefix.rs @@ -255,6 +255,7 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( invocation_data, trigger, })?; + let action = match command.prefix_action { Some(x) => x, // This command doesn't have a prefix implementation @@ -293,6 +294,14 @@ pub async fn run_invocation( return Ok(()); } + if ctx.command.subcommand_required { + // None of this command's subcommands were invoked, or else we'd have the subcommand in + // ctx.command and not the parent command + return Err(crate::FrameworkError::SubcommandRequired { + ctx: crate::Context::Prefix(ctx), + }); + } + super::common::check_permissions_and_cooldown(ctx.into()).await?; // Typing is broadcasted as long as this object is alive diff --git a/src/modal.rs b/src/modal.rs index c54d4cf6c17d..5cf4086b23a4 100644 --- a/src/modal.rs +++ b/src/modal.rs @@ -1,5 +1,7 @@ //! Modal trait and utility items for implementing it (mainly for the derive macro) +use std::sync::Arc; + use crate::serenity_prelude as serenity; /// Meant for use in derived [`Modal::parse`] implementation @@ -35,6 +37,43 @@ pub fn find_modal_text( None } +/// Underlying code for the modal spawning convenience function which abstracts over the kind of +/// interaction +async fn execute_modal_generic< + M: Modal, + F: std::future::Future>, +>( + ctx: &serenity::Context, + create_interaction_response: impl FnOnce(serenity::CreateInteractionResponse<'static>) -> F, + modal_custom_id: String, + defaults: Option, + timeout: Option, +) -> Result, serenity::Error> { + // Send modal + create_interaction_response(M::create(defaults, modal_custom_id.clone())).await?; + + // Wait for user to submit + let response = serenity::CollectModalInteraction::new(&ctx.shard) + .filter(move |d| d.data.custom_id == modal_custom_id) + .timeout(timeout.unwrap_or(std::time::Duration::from_secs(3600))) + .await; + let response = match response { + Some(x) => x, + None => return Ok(None), + }; + + // Send acknowledgement so that the pop-up is closed + response + .create_interaction_response(ctx, |b| { + b.kind(serenity::InteractionResponseType::DeferredUpdateMessage) + }) + .await?; + + Ok(Some( + M::parse(response.data.clone()).map_err(serenity::Error::Other)?, + )) +} + /// Convenience function for showing the modal and waiting for a response. /// /// If the user doesn't submit before the timeout expires, `None` is returned. @@ -56,38 +95,55 @@ pub async fn execute_modal( timeout: Option, ) -> Result, serenity::Error> { let interaction = ctx.interaction.unwrap(); - let interaction_id = interaction.id.to_string(); - - // Send modal - interaction - .create_interaction_response(ctx.serenity_context, |b| { - *b = M::create(defaults, interaction_id.clone()); - b - }) - .await?; + let response = execute_modal_generic( + ctx.serenity_context, + |resp| { + interaction.create_interaction_response(ctx.http(), |b| { + *b = resp; + b + }) + }, + interaction.id.to_string(), + defaults, + timeout, + ) + .await?; ctx.has_sent_initial_response .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(response) +} - // Wait for user to submit - let response = serenity::CollectModalInteraction::new(&ctx.serenity_context.shard) - .filter(move |d| d.data.custom_id == interaction_id) - .timeout(timeout.unwrap_or(std::time::Duration::from_secs(3600))) - .await; - let response = match response { - Some(x) => x, - None => return Ok(None), - }; - - // Send acknowledgement so that the pop-up is closed - response - .create_interaction_response(ctx.serenity_context, |b| { - b.kind(serenity::InteractionResponseType::DeferredUpdateMessage) - }) - .await?; - - Ok(Some( - M::parse(response.data.clone()).map_err(serenity::Error::Other)?, - )) +/// Convenience function for showing the modal on a message interaction and waiting for a response. +/// +/// If the user doesn't submit before the timeout expires, `None` is returned. +/// +/// This function: +/// 1. sends the modal via [`Modal::create()`] as a mci interaction response +/// 2. waits for the user to submit via [`serenity::CollectModalInteraction`] +/// 3. acknowledges the submitted data so that Discord closes the pop-up for the user +/// 4. parses the submitted data via [`Modal::parse()`], wrapping errors in [`serenity::Error::Other`] +/// +/// If you need more specialized behavior, you can copy paste the implementation of this function +/// and adjust to your needs. The code of this function is just a starting point. +pub async fn execute_modal_on_component_interaction( + ctx: impl AsRef, + interaction: Arc, + defaults: Option, + timeout: Option, +) -> Result, serenity::Error> { + execute_modal_generic( + ctx.as_ref(), + |resp| { + interaction.create_interaction_response(ctx.as_ref(), |b| { + *b = resp; + b + }) + }, + interaction.id.to_string(), + defaults, + timeout, + ) + .await } /// Derivable trait for modal interactions, Discords version of interactive forms @@ -142,6 +198,8 @@ pub trait Modal: Sized { fn parse(data: serenity::ModalSubmitInteractionData) -> Result; /// Calls `execute_modal(ctx, None, None)`. See [`execute_modal`] + /// + /// For a variant that is triggered on component interactions, see [`execute_modal_on_component_interaction`]. // TODO: add execute_with_defaults? Or add a `defaults: Option` param? async fn execute( ctx: crate::ApplicationContext<'_, U, E>, diff --git a/src/structs/command.rs b/src/structs/command.rs index 6d1e17344a81..e325ca544486 100644 --- a/src/structs/command.rs +++ b/src/structs/command.rs @@ -30,6 +30,8 @@ pub struct Command { // ============= Command type agnostic data /// Subcommands of this command, if any pub subcommands: Vec>, + /// Require a subcommand to be invoked + pub subcommand_required: bool, /// Main name of the command. Aliases (prefix-only) can be set in [`Self::aliases`]. pub name: String, /// Localized names with locale string as the key (slash-only) @@ -55,7 +57,10 @@ pub struct Command { /// help: `~help command_name` pub help_text: Option, /// Handles command cooldowns. Mainly for framework internal use - pub cooldowns: std::sync::Mutex, + pub cooldowns: std::sync::Mutex, + /// The [`CooldownConfig`](crate::CooldownConfig) that will be used + /// with the [`CooldownTracker`](crate::CooldownTracker) + pub cooldown_config: std::sync::Mutex, /// After the first response, whether to post subsequent responses as edits to the initial /// message /// diff --git a/src/structs/context.rs b/src/structs/context.rs index 0c706714bbe2..f24b81adb403 100644 --- a/src/structs/context.rs +++ b/src/structs/context.rs @@ -132,6 +132,23 @@ context_methods! { crate::say_reply(self, text).await } + /// Like [`Self::say`], but formats the message as a reply to the user's command + /// message. + /// + /// Equivalent to `.send(|b| b.content("...").reply(true))`. + /// + /// Only has an effect in prefix context, because slash command responses are always + /// formatted as a reply. + /// + /// Note: panics when called in an autocomplete context! + await (reply self text) + (pub async fn reply( + self, + text: impl Into, + ) -> Result, serenity::Error>) { + self.send(|b| b.content(text).reply(true)).await + } + /// Shorthand of [`crate::send_reply`] /// /// Note: panics when called in an autocomplete context! @@ -568,6 +585,13 @@ impl AsRef for Context<'_, U, E> { &self.serenity_context().shard } } +// Originally added as part of component interaction modals; not sure if this impl is really +// required by anything else... It makes sense to have though imo +impl AsRef for Context<'_, U, E> { + fn as_ref(&self) -> &serenity::Context { + self.serenity_context() + } +} impl serenity::CacheHttp for Context<'_, U, E> { fn http(&self) -> &serenity::Http { &self.serenity_context().http diff --git a/src/structs/framework_error.rs b/src/structs/framework_error.rs index 8fe94972176f..58da62f9c16c 100644 --- a/src/structs/framework_error.rs +++ b/src/structs/framework_error.rs @@ -45,6 +45,11 @@ pub enum FrameworkError<'a, U, E> { /// General context ctx: crate::Context<'a, U, E>, }, + /// Command was invoked without specifying a subcommand, but the command has `subcommand_required` set + SubcommandRequired { + /// General context + ctx: crate::Context<'a, U, E>, + }, /// Panic occured at any phase of command execution after constructing the `crate::Context`. /// /// This feature is intended as a last-resort safeguard to gracefully print an error message to @@ -204,6 +209,7 @@ impl<'a, U, E> FrameworkError<'a, U, E> { Self::Setup { ctx, .. } => ctx, Self::EventHandler { ctx, .. } => ctx, Self::Command { ctx, .. } => ctx.serenity_context(), + Self::SubcommandRequired { ctx } => ctx.serenity_context(), Self::CommandPanic { ctx, .. } => ctx.serenity_context(), Self::ArgumentParse { ctx, .. } => ctx.serenity_context(), Self::CommandStructureMismatch { ctx, .. } => ctx.serenity_context, @@ -226,6 +232,7 @@ impl<'a, U, E> FrameworkError<'a, U, E> { pub fn ctx(&self) -> Option> { Some(match *self { Self::Command { ctx, .. } => ctx, + Self::SubcommandRequired { ctx } => ctx, Self::CommandPanic { ctx, .. } => ctx, Self::ArgumentParse { ctx, .. } => ctx, Self::CommandStructureMismatch { ctx, .. } => crate::Context::Application(ctx), @@ -304,6 +311,13 @@ impl std::fmt::Display for FrameworkError<'_, U, E> { Self::Command { error: _, ctx } => { write!(f, "error in command `{}`", full_command_name!(ctx)) } + Self::SubcommandRequired { ctx } => { + write!( + f, + "expected subcommand for command `{}`", + full_command_name!(ctx) + ) + } Self::CommandPanic { ctx, payload: _ } => { write!(f, "panic in command `{}`", full_command_name!(ctx)) } @@ -405,6 +419,7 @@ impl<'a, U: std::fmt::Debug, E: std::error::Error + 'static> std::error::Error Self::Setup { error, .. } => Some(error), Self::EventHandler { error, .. } => Some(error), Self::Command { error, .. } => Some(error), + Self::SubcommandRequired { .. } => None, Self::CommandPanic { .. } => None, Self::ArgumentParse { error, .. } => Some(&**error), Self::CommandStructureMismatch { .. } => None, diff --git a/src/structs/framework_options.rs b/src/structs/framework_options.rs index 92979bcf9fef..bbaa6aa83940 100644 --- a/src/structs/framework_options.rs +++ b/src/structs/framework_options.rs @@ -37,7 +37,7 @@ pub struct FrameworkOptions { /// If `true`, disables automatic cooldown handling before every command invocation. /// /// Useful for implementing custom cooldown behavior. See [`crate::Command::cooldowns`] and - /// the methods on [`crate::Cooldowns`] for how to do that. + /// the methods on [`crate::CooldownTracker`] for how to do that. pub manual_cooldowns: bool, /// If `true`, changes behavior of guild_only command check to abort execution if the guild is /// not in cache.