diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306faec8fc03..1e33f4c4a6d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: include: - name: MSRV - toolchain: 1.74.0 + toolchain: 1.80.0 # don't do doctests because they rely on new features for brevity # copy known Cargo.lock to avoid random dependency MSRV bumps to mess up our test command: cp .github/Cargo-msrv.lock Cargo.lock && cargo test --all-features --lib --tests diff --git a/Cargo.toml b/Cargo.toml index 6f23c262fb7f..65b26c8df06d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ authors = ["kangalio "] edition = "2021" name = "poise" version = "0.6.1" -rust-version = "1.74.0" +rust-version = "1.80.0" description = "A Discord bot framework for serenity" license = "MIT" repository = "https://github.com/serenity-rs/poise/" diff --git a/README.md b/README.md index 628d83fb3e13..f65dd020256d 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.74+](https://img.shields.io/badge/rust-1.74+-93450a)](https://blog.rust-lang.org/2023/11/16/Rust-1.74.0.html) +[![Rust: 1.80+](https://img.shields.io/badge/rust-1.80+-93450a)](https://blog.rust-lang.org/2024/07/25/Rust-1.80.0.html) # Poise Poise is an opinionated Discord bot framework with a few distinctive features: diff --git a/examples/README.md b/examples/README.md index 74d4f2264135..5eb1890eacb4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,8 +9,7 @@ by poise. # basic_structure -Showcases the basics of poise: `FrameworkOptions`, creating and accessing the data struct, a help -command, defining commands and sending responses. +Showcases the basics of poise: `FrameworkOptions`, creating and accessing the data struct, defining commands and sending responses. # feature_showcase diff --git a/examples/basic_structure/commands.rs b/examples/basic_structure/commands.rs index 376da639e3c2..5262615aaaf6 100644 --- a/examples/basic_structure/commands.rs +++ b/examples/basic_structure/commands.rs @@ -1,25 +1,5 @@ use crate::{Context, Error}; -/// Show this help menu -#[poise::command(prefix_command, track_edits, slash_command)] -pub async fn help( - ctx: Context<'_>, - #[description = "Specific command to show help about"] - #[autocomplete = "poise::builtins::autocomplete_command"] - command: Option, -) -> Result<(), Error> { - poise::builtins::help( - ctx, - command.as_deref(), - poise::builtins::HelpConfiguration { - extra_text_at_bottom: "This is an example bot made to showcase features of my custom Discord bot framework", - ..Default::default() - }, - ) - .await?; - Ok(()) -} - /// Vote for something /// /// Enter `~vote pumpkin` to vote for pumpkins diff --git a/examples/basic_structure/main.rs b/examples/basic_structure/main.rs index a97bc68b0795..d8d6f70b35ae 100644 --- a/examples/basic_structure/main.rs +++ b/examples/basic_structure/main.rs @@ -43,7 +43,7 @@ async fn main() { // FrameworkOptions contains all of poise's configuration option in one struct // Every option can be omitted to use its default value let options = poise::FrameworkOptions { - commands: vec![commands::help(), commands::vote(), commands::getvotes()], + commands: vec![commands::vote(), commands::getvotes()], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("~".into()), edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( @@ -81,7 +81,7 @@ async fn main() { // Enforce command checks even for owners (enforced by default) // Set to true to bypass checks, which is useful for testing skip_checks_for_owners: false, - event_handler: |_ctx, event, _framework, _data| { + event_handler: |_framework, event| { Box::pin(async move { println!( "Got an event in event handler: {:?}", diff --git a/examples/event_handler/main.rs b/examples/event_handler/main.rs index 34fb9fbb2cf7..a7b502d36d43 100644 --- a/examples/event_handler/main.rs +++ b/examples/event_handler/main.rs @@ -31,9 +31,7 @@ async fn main() { }) }) .options(poise::FrameworkOptions { - event_handler: |ctx, event, framework, data| { - Box::pin(event_handler(ctx, event, framework, data)) - }, + event_handler: |framework, event| Box::pin(event_handler(framework, event)), ..Default::default() }) .build(); @@ -46,11 +44,12 @@ async fn main() { } async fn event_handler( - ctx: &serenity::Context, + framework: poise::FrameworkContext<'_, Data, Error>, event: &serenity::FullEvent, - _framework: poise::FrameworkContext<'_, Data, Error>, - data: &Data, ) -> Result<(), Error> { + let data = framework.user_data; + let ctx = framework.serenity_context; + match event { serenity::FullEvent::Ready { data_about_bot, .. } => { println!("Logged in as {}", data_about_bot.user.name); diff --git a/examples/feature_showcase/autocomplete.rs b/examples/feature_showcase/autocomplete.rs index 1997686a01b0..20e2b8ba42a7 100644 --- a/examples/feature_showcase/autocomplete.rs +++ b/examples/feature_showcase/autocomplete.rs @@ -1,4 +1,3 @@ -use futures::{Stream, StreamExt}; use std::fmt::Write as _; use poise::serenity_prelude as serenity; @@ -11,40 +10,34 @@ use crate::{Context, Error}; // The first parameter of that function is ApplicationContext or Context, and the second parameter // is a &str of the partial input which the user has typed so far. // -// As the return value of autocomplete functions, you can return a Stream, an Iterator, or an -// IntoIterator like Vec and [T; N]. -// -// The returned collection type must be a &str/String (or number, if you're implementing -// autocomplete on a number type). Wrap the type in serenity::AutocompleteChoice to set a custom label -// for each choice which will be displayed in the Discord UI. -// -// Example function return types (assuming non-number parameter -> autocomplete choices are string): -// - `-> impl Stream` -// - `-> Vec` -// - `-> impl Iterator` -// - `-> impl Iterator<&str>` -// - `-> impl Iterator +// As the return value of autocomplete functions, you must return `serenity::CreateAutocompleteResponse`. async fn autocomplete_name<'a>( _ctx: Context<'_>, partial: &'a str, -) -> impl Stream + 'a { - futures::stream::iter(&["Amanda", "Bob", "Christian", "Danny", "Ester", "Falk"]) - .filter(move |name| futures::future::ready(name.starts_with(partial))) - .map(|name| name.to_string()) +) -> serenity::CreateAutocompleteResponse { + let choices = ["Amanda", "Bob", "Christian", "Danny", "Ester", "Falk"] + .into_iter() + .filter(move |name| name.starts_with(partial)) + .map(serenity::AutocompleteChoice::from) + .collect(); + + serenity::CreateAutocompleteResponse::new().set_choices(choices) } async fn autocomplete_number( _ctx: Context<'_>, _partial: &str, -) -> impl Iterator { +) -> serenity::CreateAutocompleteResponse { // Dummy choices - [1_u32, 2, 3, 4, 5].iter().map(|&n| { + let choices = [1_u32, 2, 3, 4, 5].iter().map(|&n| { serenity::AutocompleteChoice::new( format!("{n} (why did discord even give autocomplete choices separate labels)"), n, ) - }) + }); + + serenity::CreateAutocompleteResponse::new().set_choices(choices.collect()) } /// Greet a user. Showcasing autocomplete! diff --git a/examples/feature_showcase/builtins.rs b/examples/feature_showcase/builtins.rs index a7cab6857e95..bb9f01a2ac07 100644 --- a/examples/feature_showcase/builtins.rs +++ b/examples/feature_showcase/builtins.rs @@ -6,23 +6,3 @@ pub async fn servers(ctx: Context<'_>) -> Result<(), Error> { poise::builtins::servers(ctx).await?; Ok(()) } - -#[poise::command(slash_command, prefix_command)] -pub async fn help(ctx: Context<'_>, command: Option) -> Result<(), Error> { - let configuration = poise::builtins::HelpConfiguration { - // [configure aspects about the help message here] - ..Default::default() - }; - poise::builtins::help(ctx, command.as_deref(), configuration).await?; - Ok(()) -} - -#[poise::command(slash_command, prefix_command)] -pub async fn pretty_help(ctx: Context<'_>, command: Option) -> Result<(), Error> { - let configuration = poise::builtins::PrettyHelpConfiguration { - // [configure aspects about the help message here] - ..Default::default() - }; - poise::builtins::pretty_help(ctx, command.as_deref(), configuration).await?; - Ok(()) -} diff --git a/examples/feature_showcase/choice_parameter.rs b/examples/feature_showcase/choice_parameter.rs index 932eb16495ca..17d0953a4d46 100644 --- a/examples/feature_showcase/choice_parameter.rs +++ b/examples/feature_showcase/choice_parameter.rs @@ -27,7 +27,7 @@ pub async fn choice( // // Limitations: due to macro limitations (partially self-imposed, partially external), poise // currently does not support Options parameters, and only supports parameter types that can be -// constructed from a literal (https://doc.rust-lang.org/reference/expressions/literal-expr.html). +// constructed from a literal which can be converted to a string at compile time. #[poise::command(slash_command)] pub async fn inline_choice( diff --git a/examples/feature_showcase/main.rs b/examples/feature_showcase/main.rs index c5424df67395..e74f7ddca8e0 100644 --- a/examples/feature_showcase/main.rs +++ b/examples/feature_showcase/main.rs @@ -40,8 +40,6 @@ async fn main() { bool_parameter::oracle(), #[cfg(feature = "cache")] builtins::servers(), - builtins::help(), - builtins::pretty_help(), checks::shutdown(), checks::modonly(), checks::delete(), @@ -84,7 +82,7 @@ async fn main() { ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("~".into()), - non_command_message: Some(|_, _, msg| { + non_command_message: Some(|_, msg| { Box::pin(async move { println!("non command message!: {}", msg.content); Ok(()) diff --git a/examples/feature_showcase/modal.rs b/examples/feature_showcase/modal.rs index 99a7fbaef046..e4f1dbf25c5b 100644 --- a/examples/feature_showcase/modal.rs +++ b/examples/feature_showcase/modal.rs @@ -37,13 +37,15 @@ pub async fn component_modal(ctx: crate::Context<'_>) -> Result<(), Error> { ctx.send(reply).await?; - while let Some(mci) = serenity::ComponentInteractionCollector::new(ctx.serenity_context()) + let serenity_ctx = ctx.serenity_context(); + while let Some(mci) = serenity::ComponentInteractionCollector::new(serenity_ctx) .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?; + poise::execute_modal_on_component_interaction::(serenity_ctx, mci, None, None) + .await?; println!("Got data: {:?}", data); } Ok(()) diff --git a/examples/fluent_localization/main.rs b/examples/fluent_localization/main.rs index f215a3a99584..db82185bf342 100644 --- a/examples/fluent_localization/main.rs +++ b/examples/fluent_localization/main.rs @@ -4,7 +4,7 @@ use poise::serenity_prelude as serenity; use translation::tr; pub struct Data { - translations: translation::Translations, + translations: &'static translation::Translations, } type Error = Box; @@ -62,7 +62,12 @@ async fn main() { let mut commands = vec![welcome(), info(), register()]; let translations = translation::read_ftl().expect("failed to read translation files"); - translation::apply_translations(&translations, &mut commands); + + // We leak the translations so we can easily copy around `&'static str`s, to the downside + // that the OS will reclaim the memory at the end of `main` instead of the Drop implementation. + let translations: &'static translation::Translations = Box::leak(Box::new(translations)); + + translation::apply_translations(translations, &mut commands); let token = std::env::var("TOKEN").unwrap(); let intents = serenity::GatewayIntents::non_privileged(); diff --git a/examples/fluent_localization/translation.rs b/examples/fluent_localization/translation.rs index 250c20e40650..2961550000c6 100644 --- a/examples/fluent_localization/translation.rs +++ b/examples/fluent_localization/translation.rs @@ -1,5 +1,7 @@ //! Wraps the fluent API and provides easy to use functions and macros for translation +use std::borrow::Cow; + use crate::{Context, Data, Error}; type FluentBundle = fluent::bundle::FluentBundle< @@ -30,27 +32,27 @@ pub(crate) use tr; /// Given a language file and message identifier, returns the translation pub fn format( - bundle: &FluentBundle, + bundle: &'static FluentBundle, id: &str, attr: Option<&str>, args: Option<&fluent::FluentArgs<'_>>, -) -> Option { +) -> Option> { let message = bundle.get_message(id)?; let pattern = match attr { Some(attribute) => message.get_attribute(attribute)?.value(), None => message.value()?, }; let formatted = bundle.format_pattern(pattern, args, &mut vec![]); - Some(formatted.into_owned()) + Some(formatted) } /// Retrieves the appropriate language file depending on user locale and calls [`format`] pub fn get( ctx: Context, - id: &str, + id: &'static str, attr: Option<&str>, args: Option<&fluent::FluentArgs<'_>>, -) -> String { +) -> Cow<'static, str> { let translations = &ctx.data().translations; ctx.locale() // Try to get the language-specific translation @@ -60,7 +62,7 @@ pub fn get( // If this message ID is not present in any translation files whatsoever .unwrap_or_else(|| { tracing::warn!("unknown fluent message identifier `{}`", id); - id.to_string() + Cow::Borrowed(id) }) } @@ -97,7 +99,7 @@ pub fn read_ftl() -> Result { /// Given a set of language files, fills in command strings and their localizations accordingly pub fn apply_translations( - translations: &Translations, + translations: &'static Translations, commands: &mut [poise::Command], ) { for command in &mut *commands { @@ -108,21 +110,24 @@ pub fn apply_translations( Some(x) => x, None => continue, // no localization entry => skip localization }; - command - .name_localizations - .insert(locale.clone(), localized_command_name); - command.description_localizations.insert( + + let locale = Cow::Borrowed(locale.as_str()); + let name_localizations = command.name_localizations.to_mut(); + let description_localizations = command.description_localizations.to_mut(); + + name_localizations.push((locale.clone(), localized_command_name)); + description_localizations.push(( locale.clone(), format(bundle, &command.name, Some("description"), None).unwrap(), - ); + )); for parameter in &mut command.parameters { // Insert localized parameter name and description - parameter.name_localizations.insert( + parameter.name_localizations.to_mut().push(( locale.clone(), format(bundle, &command.name, Some(¶meter.name), None).unwrap(), - ); - parameter.description_localizations.insert( + )); + parameter.description_localizations.to_mut().push(( locale.clone(), format( bundle, @@ -131,14 +136,14 @@ pub fn apply_translations( None, ) .unwrap(), - ); + )); // If this is a choice parameter, insert its localized variants - for choice in &mut parameter.choices { - choice.localizations.insert( + for choice in parameter.choices.to_mut().iter_mut() { + choice.localizations.to_mut().push(( locale.clone(), format(bundle, &choice.name, None, None).unwrap(), - ); + )); } } } @@ -170,7 +175,7 @@ pub fn apply_translations( ); // If this is a choice parameter, set the choice names to en-US - for choice in &mut parameter.choices { + for choice in parameter.choices.to_mut().iter_mut() { choice.name = format(bundle, &choice.name, None, None).unwrap(); } } diff --git a/examples/help_generation/main.rs b/examples/help_generation/main.rs deleted file mode 100644 index 756372918930..000000000000 --- a/examples/help_generation/main.rs +++ /dev/null @@ -1,358 +0,0 @@ -use poise::{samples::HelpConfiguration, serenity_prelude as serenity}; -use rand::Rng; - -struct Data {} // User data, which is stored and accessible in all command invocations -type Error = Box; -type Context<'a> = poise::Context<'a, Data, Error>; - -const FRUIT: &[&str] = &["🍎", "🍌", "🍊", "🍉", "🍇", "🍓"]; -const VEGETABLES: &[&str] = &["🥕", "🥦", "🥬", "🥒", "🌽", "🥔"]; -const MEAT: &[&str] = &["🥩", "🍗", "🍖", "🥓", "🍔", "🍕"]; -const DAIRY: &[&str] = &["🥛", "🧀", "🍦", "🍨", "🍩", "🍪"]; -const FOOD: &[&str] = &[ - "🍎", "🍌", "🍊", "🍉", "🍇", "🍓", "🥕", "🥦", "🥬", "🥒", "🌽", "🥔", "🥩", "🍗", "🍖", "🥓", - "🍔", "🍕", "🥛", "🧀", "🍦", "🍨", "🍩", "🍪", -]; - -fn ninetynine_bottles() -> String { - let mut bottles = String::new(); - for i in (95..100).rev() { - bottles.push_str(&format!( - "{0} bottles of beer on the wall, {0} bottles of beer!\n", - i - )); - bottles.push_str(&format!( - "Take one down, pass it around, {0} bottles of beer on the wall!\n", - i - 1 - )); - } - bottles += "That's quite enough to demonstrate this function!"; - bottles -} - -#[poise::command( - slash_command, - prefix_command, - category = "Vegan", - help_text_fn = "ninetynine_bottles" -)] -async fn beer(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍺").await?; - Ok(()) -} - -/// Respond with a random fruit -/// -/// Subcommands can be used to get a specific fruit -#[poise::command( - slash_command, - prefix_command, - subcommands( - "apple", - "banana", - "orange", - "watermelon", - "grape", - "strawberry", - "help" - ), - category = "Vegan" -)] -async fn fruit(ctx: Context<'_>) -> Result<(), Error> { - let response = FRUIT[rand::thread_rng().gen_range(0..FRUIT.len())]; - ctx.say(response).await?; - Ok(()) -} - -/// Respond with an apple -#[poise::command(slash_command, prefix_command, subcommands("red", "green"))] -async fn apple(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍎").await?; - Ok(()) -} - -/// Respond with a red apple -#[poise::command(slash_command, prefix_command)] -async fn red(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍎").await?; - Ok(()) -} - -/// Respond with a green apple -#[poise::command(slash_command, prefix_command)] -async fn green(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍏").await?; - Ok(()) -} - -/// Respond with a banana -#[poise::command(slash_command, prefix_command)] -async fn banana(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍌").await?; - Ok(()) -} - -/// Respond with an orange -#[poise::command(slash_command, prefix_command)] -async fn orange(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍊").await?; - Ok(()) -} - -/// Respond with a watermelon -#[poise::command(slash_command, prefix_command)] -async fn watermelon(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍉").await?; - Ok(()) -} - -/// Respond with a grape -#[poise::command(slash_command, prefix_command)] -async fn grape(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍇").await?; - Ok(()) -} - -/// Respond with a strawberry -#[poise::command(slash_command, prefix_command)] -async fn strawberry(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("🍓").await?; - Ok(()) -} - -/// Respond with a random vegetable -#[poise::command(slash_command, prefix_command, category = "Vegan")] -async fn vegetable(ctx: Context<'_>) -> Result<(), Error> { - let response = VEGETABLES[rand::thread_rng().gen_range(0..VEGETABLES.len())]; - ctx.say(response).await?; - Ok(()) -} - -/// Respond with a random meat -#[poise::command(slash_command, prefix_command, category = "Other")] -async fn meat(ctx: Context<'_>) -> Result<(), Error> { - let response = MEAT[rand::thread_rng().gen_range(0..MEAT.len())]; - ctx.say(response).await?; - Ok(()) -} - -/// Respond with a random dairy product -#[poise::command(slash_command, prefix_command, category = "Other")] -async fn dairy(ctx: Context<'_>) -> Result<(), Error> { - let response = DAIRY[rand::thread_rng().gen_range(0..DAIRY.len())]; - ctx.say(response).await?; - Ok(()) -} - -/// Give a user some random food -#[poise::command(context_menu_command = "Give food")] -async fn context_food( - ctx: Context<'_>, - #[description = "User to give food to"] user: serenity::User, -) -> Result<(), Error> { - let response = format!( - "<@{}>: {}", - user.id, - FOOD[rand::thread_rng().gen_range(0..FOOD.len())] - ); - - ctx.say(response).await?; - Ok(()) -} - -/// Give a user some random fruit -#[poise::command( - slash_command, - context_menu_command = "Give fruit", - category = "Context menu but also slash/prefix" -)] -async fn context_fruit( - ctx: Context<'_>, - #[description = "User to give fruit to"] user: serenity::User, -) -> Result<(), Error> { - let response = format!( - "<@{}>: {}", - user.id, - FRUIT[rand::thread_rng().gen_range(0..FRUIT.len())] - ); - - ctx.say(response).await?; - Ok(()) -} - -/// Give a user some random vegetable -#[poise::command( - prefix_command, - context_menu_command = "Give vegetable", - category = "Context menu but also slash/prefix" -)] -async fn context_vegetable( - ctx: Context<'_>, - #[description = "User to give vegetable to"] user: serenity::User, -) -> Result<(), Error> { - let response = format!( - "<@{}>: {}", - user.id, - VEGETABLES[rand::thread_rng().gen_range(0..VEGETABLES.len())] - ); - - ctx.say(response).await?; - Ok(()) -} - -/// Give a user some random meat -#[poise::command( - prefix_command, - slash_command, - context_menu_command = "Give meat", - category = "Context menu but also slash/prefix" -)] -async fn context_meat( - ctx: Context<'_>, - #[description = "User to give meat to"] user: serenity::User, -) -> Result<(), Error> { - let response = format!( - "<@{}>: {}", - user.id, - MEAT[rand::thread_rng().gen_range(0..MEAT.len())] - ); - - ctx.say(response).await?; - Ok(()) -} - -/// React to a message with random food -// This command intentionally doesn't have a slash/prefix command, and its own -// category, so that we can test whether the category shows up in the help -// message. It shouldn't. -#[poise::command( - context_menu_command = "React with food", - ephemeral, - category = "No slash/prefix", - subcommands("fruit_react", "vegetable_react") -)] -async fn food_react( - ctx: Context<'_>, - #[description = "Message to react to (enter a link or ID)"] msg: serenity::Message, -) -> Result<(), Error> { - let reaction = FOOD[rand::thread_rng().gen_range(0..FOOD.len())].to_string(); - msg.react(ctx, serenity::ReactionType::Unicode(reaction)) - .await?; - ctx.say("Reacted!").await?; - Ok(()) -} - -// These next two commands are subcommands of `food_react`, so they're not -// visible in the overview help command. But they should still show up in -// `?help react with food` - -/// React to a message with a random fruit -#[poise::command( - slash_command, - context_menu_command = "React with fruit", - ephemeral, - category = "No slash/prefix" -)] -async fn fruit_react( - ctx: Context<'_>, - #[description = "Message to react to (enter a link or ID)"] msg: serenity::Message, -) -> Result<(), Error> { - let reaction = FRUIT[rand::thread_rng().gen_range(0..FRUIT.len())].to_string(); - msg.react(ctx, serenity::ReactionType::Unicode(reaction)) - .await?; - ctx.say("Reacted!").await?; - Ok(()) -} - -/// React to a message with a random vegetable -#[poise::command( - slash_command, - context_menu_command = "React with vegetable", - ephemeral, - category = "No slash/prefix" -)] -async fn vegetable_react( - ctx: Context<'_>, - #[description = "Message to react to (enter a link or ID)"] msg: serenity::Message, -) -> Result<(), Error> { - let reaction = VEGETABLES[rand::thread_rng().gen_range(0..VEGETABLES.len())].to_string(); - msg.react(ctx, serenity::ReactionType::Unicode(reaction)) - .await?; - ctx.say("Reacted!").await?; - Ok(()) -} - -/// Show help message -#[poise::command(prefix_command, track_edits, category = "Utility")] -async fn help( - ctx: Context<'_>, - #[description = "Command to get help for"] - #[rest] - mut command: Option, -) -> Result<(), Error> { - // This makes it possible to just make `help` a subcommand of any command - // `/fruit help` turns into `/help fruit` - // `/fruit help apple` turns into `/help fruit apple` - if ctx.invoked_command_name() != "help" { - command = match command { - Some(c) => Some(format!("{} {}", ctx.invoked_command_name(), c)), - None => Some(ctx.invoked_command_name().to_string()), - }; - } - let extra_text_at_bottom = "\ -Type `?help command` for more info on a command. -You can edit your `?help` message to the bot and the bot will edit its response."; - - let config = HelpConfiguration { - show_subcommands: true, - show_context_menu_commands: true, - ephemeral: true, - extra_text_at_bottom, - - ..Default::default() - }; - poise::builtins::help(ctx, command.as_deref(), config).await?; - Ok(()) -} - -#[tokio::main] -async fn main() { - let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); - let intents = - serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; - - let framework = poise::Framework::builder() - .options(poise::FrameworkOptions { - commands: vec![ - fruit(), - vegetable(), - beer(), - meat(), - dairy(), - help(), - context_food(), - context_fruit(), - context_vegetable(), - context_meat(), - food_react(), - ], - prefix_options: poise::PrefixFrameworkOptions { - prefix: Some("?".into()), - ..Default::default() - }, - ..Default::default() - }) - .setup(|ctx, _ready, framework| { - Box::pin(async move { - poise::builtins::register_globally(ctx, &framework.options().commands).await?; - Ok(Data {}) - }) - }) - .build(); - - let client = serenity::ClientBuilder::new(token, intents) - .framework(framework) - .await; - - client.unwrap().start().await.unwrap(); -} diff --git a/examples/invocation_data/main.rs b/examples/invocation_data/main.rs index 21c18e08600f..751e9ab30a39 100644 --- a/examples/invocation_data/main.rs +++ b/examples/invocation_data/main.rs @@ -18,13 +18,13 @@ async fn my_check(ctx: Context<'_>) -> Result { Ok(true) } -async fn my_autocomplete(ctx: Context<'_>, _: &str) -> impl Iterator { +async fn my_autocomplete(ctx: Context<'_>, _: &str) -> serenity::CreateAutocompleteResponse { println!( "In autocomplete: {:?}", ctx.invocation_data::<&str>().await.as_deref() ); - std::iter::empty() + serenity::CreateAutocompleteResponse::new() } /// Test command to ensure that invocation_data works diff --git a/examples/manual_dispatch/main.rs b/examples/manual_dispatch/main.rs index f6595ae20a7f..19e4ef82ec1a 100644 --- a/examples/manual_dispatch/main.rs +++ b/examples/manual_dispatch/main.rs @@ -24,14 +24,14 @@ impl serenity::EventHandler for Handler { // FrameworkContext contains all data that poise::Framework usually manages let shard_manager = (*self.shard_manager.lock().unwrap()).clone().unwrap(); let framework_data = poise::FrameworkContext { - bot_id: serenity::UserId::new(846453852164587620), + serenity_context: &ctx, options: &self.options, user_data: &(), shard_manager: &shard_manager, }; let event = serenity::FullEvent::Message { new_message }; - poise::dispatch_event(framework_data, &ctx, event).await; + poise::dispatch_event(framework_data, event).await; } // For slash commands or edit tracking to work, forward interaction_create and message_update diff --git a/macros/src/choice_parameter.rs b/macros/src/choice_parameter.rs index e84005371af1..0e66eea39e1f 100644 --- a/macros/src/choice_parameter.rs +++ b/macros/src/choice_parameter.rs @@ -67,14 +67,16 @@ pub fn choice_parameter(input: syn::DeriveInput) -> Result Vec { - vec![ #( poise::CommandParameterChoice { + fn list() -> std::borrow::Cow<'static, [poise::CommandParameterChoice]> { + use ::std::borrow::Cow; + + Cow::Borrowed(&[ #( poise::CommandParameterChoice { __non_exhaustive: (), - name: #names.to_string(), - localizations: std::collections::HashMap::from([ - #( (#locales.to_string(), #localized_names.to_string()) ),* + name: Cow::Borrowed(#names), + localizations: Cow::Borrowed(&[ + #( (Cow::Borrowed(#locales), Cow::Borrowed(#localized_names)) ),* ]), - }, )* ] + }, )* ]) } fn from_index(index: usize) -> Option { diff --git a/macros/src/command/mod.rs b/macros/src/command/mod.rs index 26ebdd90d87d..3fdb8ecdff71 100644 --- a/macros/src/command/mod.rs +++ b/macros/src/command/mod.rs @@ -2,7 +2,7 @@ mod prefix; mod slash; use crate::util::{ - iter_tuple_2_to_hash_map, wrap_option, wrap_option_and_map, wrap_option_to_string, + iter_tuple_2_to_vec_map, wrap_option, wrap_option_and_map, wrap_option_to_string, }; use proc_macro::TokenStream; use syn::spanned::Spanned as _; @@ -26,7 +26,6 @@ pub struct CommandArgs { track_deletion: bool, track_edits: bool, broadcast_typing: bool, - help_text_fn: Option, #[darling(multiple)] check: Vec, on_error: Option, @@ -318,12 +317,9 @@ fn generate_command(mut inv: Invocation) -> Result quote::quote! { Some(#help_text_fn()) }, - None => match &inv.help_text { - Some(extracted_explanation) => quote::quote! { Some(#extracted_explanation.into()) }, - None => quote::quote! { None }, - }, + let help_text = match &inv.help_text { + Some(extracted_explanation) => quote::quote! { Some(#extracted_explanation.into()) }, + None => quote::quote! { None }, }; let checks = &inv.args.check; @@ -347,9 +343,9 @@ fn generate_command(mut inv: Invocation) -> Result quote::quote! { Box::new(()) }, }; - let name_localizations = iter_tuple_2_to_hash_map(inv.args.name_localized.into_iter()); + let name_localizations = iter_tuple_2_to_vec_map(inv.args.name_localized.into_iter()); let description_localizations = - iter_tuple_2_to_hash_map(inv.args.description_localized.into_iter()); + iter_tuple_2_to_vec_map(inv.args.description_localized.into_iter()); let function_ident = std::mem::replace(&mut inv.function.sig.ident, syn::parse_quote! { inner }); @@ -363,6 +359,8 @@ fn generate_command(mut inv: Invocation) -> Result::U, <#ctx_type_with_static as poise::_GetGenerics>::E, > { + use ::std::borrow::Cow; + #function ::poise::Command { @@ -372,11 +370,11 @@ fn generate_command(mut inv: Invocation) -> Result Result Result + ctx.serenity_context(), ctx.msg, ctx.args, 0 => #( #param_specs, )* #wildcard_arg ).await.map_err(|(error, input)| poise::FrameworkError::new_argument_parse( diff --git a/macros/src/command/slash.rs b/macros/src/command/slash.rs index 953e2fac1f97..eccf4bd672ad 100644 --- a/macros/src/command/slash.rs +++ b/macros/src/command/slash.rs @@ -1,10 +1,60 @@ -use super::Invocation; +use super::{Invocation, ParamArgs}; use crate::util::{ - extract_type_parameter, iter_tuple_2_to_hash_map, tuple_2_iter_deref, wrap_option_to_string, + extract_type_parameter, iter_tuple_2_to_vec_map, tuple_2_iter_deref, wrap_option, + wrap_option_to_string, }; -use quote::format_ident; +use quote::{format_ident, quote}; use syn::spanned::Spanned as _; +fn lit_to_string(lit: &syn::Lit) -> Result { + match lit { + syn::Lit::Str(lit_str) => Ok(lit_str.value()), + syn::Lit::Char(lit_char) => Ok(lit_char.value().to_string()), + syn::Lit::Int(lit_int) => Ok(lit_int.base10_digits().to_owned()), + syn::Lit::Float(lit_float) => Ok(lit_float.token().to_string()), + syn::Lit::Bool(lit_bool) => Ok(lit_bool.value.to_string()), + + _ => Err(syn::Error::new( + lit.span(), + "Inline choice must be convertable to a string at compile time", + )), + } +} + +fn generate_value_limits( + args: &ParamArgs, + span: proc_macro2::Span, +) -> syn::Result> { + let limits = match (&args.min, &args.max, &args.min_length, &args.max_length) { + (None, None, None, Some(max)) => { + quote!( ::poise::ValueLimits::Length { min: None, max: Some(#max) } ) + } + (None, None, Some(min), None) => { + quote!( ::poise::ValueLimits::Length { min: Some(#min), max: None } ) + } + (None, None, Some(min), Some(max)) => { + quote!( ::poise::ValueLimits::Length { min: Some(#min), max: Some(#max) } ) + } + (None, Some(max), None, None) => { + quote!( ::poise::ValueLimits::Value { min: None, max: Some((#max) as f64) } ) + } + (Some(min), None, None, None) => { + quote!( ::poise::ValueLimits::Value { min: Some((#min) as f64), max: None } ) + } + (Some(min), Some(max), None, None) => { + quote!( ::poise::ValueLimits::Value { min: Some((#min) as f64), max: Some((#max) as f64) } ) + } + + (None, None, None, None) => return Ok(None), + _ => { + let err = "Cannot set both a `min_length/max_length` and a `min/max`"; + return Err(syn::Error::new(span, err)); + } + }; + + Ok(Some(limits)) +} + pub fn generate_parameters(inv: &Invocation) -> Result, syn::Error> { let mut parameter_structs = Vec::new(); for param in &inv.parameters { @@ -26,102 +76,69 @@ pub fn generate_parameters(inv: &Invocation) -> Result { - quote::quote! { Some(| + quote! { Some(| ctx: poise::ApplicationContext<'_, _, _>, partial: &str, - | Box::pin(async move { - use ::poise::futures_util::{Stream, StreamExt}; - - let choices_stream = ::poise::into_stream!( - #autocomplete_fn(ctx.into(), partial).await - ); - let choices_vec = choices_stream - .take(25) - // T or AutocompleteChoice -> AutocompleteChoice - .map(poise::serenity_prelude::AutocompleteChoice::from) - .collect() - .await; - - let mut response = poise::serenity_prelude::CreateAutocompleteResponse::default(); - Ok(response.set_choices(choices_vec)) - })) } + | Box::pin(#autocomplete_fn(ctx.into(), partial))) } } - None => quote::quote! { None }, + None => quote! { None }, }; - // We can just cast to f64 here because Discord only uses f64 precision anyways - // TODO: move this to poise::CommandParameter::{min, max} fields - let min_value_setter = match ¶m.args.min { - Some(x) => quote::quote! { .min_number_value(#x as f64) }, - None => quote::quote! {}, - }; - let max_value_setter = match ¶m.args.max { - Some(x) => quote::quote! { .max_number_value(#x as f64) }, - None => quote::quote! {}, - }; - // TODO: move this to poise::CommandParameter::{min_length, max_length} fields - let min_length_setter = match ¶m.args.min_length { - Some(x) => quote::quote! { .min_length(#x) }, - None => quote::quote! {}, - }; - let max_length_setter = match ¶m.args.max_length { - Some(x) => quote::quote! { .max_length(#x) }, - None => quote::quote! {}, - }; + let value_limits = wrap_option(generate_value_limits(¶m.args, param.span)?); + let type_setter = match inv.args.slash_command { true => { if let Some(_choices) = ¶m.args.choices { - quote::quote! { Some(|o| o.kind(::poise::serenity_prelude::CommandOptionType::Integer)) } + quote! { Some(|o| o.kind(::poise::serenity_prelude::CommandOptionType::Integer)) } } else { - quote::quote! { Some(|o| { - poise::create_slash_argument!(#type_, o) - #min_value_setter #max_value_setter - #min_length_setter #max_length_setter - }) } + quote! { Some(|o| poise::create_slash_argument!(#type_, o)) } } } - false => quote::quote! { None }, + false => quote! { None }, }; + // TODO: theoretically a problem that we don't store choices for non slash commands // TODO: move this to poise::CommandParameter::choices (is there a reason not to?) - let choices = match inv.args.slash_command { - true => { - if let Some(choices) = ¶m.args.choices { - let choices = &choices.0; - quote::quote! { vec![#( ::poise::CommandParameterChoice { - name: ToString::to_string(&#choices), - localizations: Default::default(), - __non_exhaustive: (), - } ),*] } - } else { - quote::quote! { poise::slash_argument_choices!(#type_) } - } + let choices = if inv.args.slash_command { + if let Some(choices) = ¶m.args.choices { + let choices_iter = choices.0.iter(); + let choices: Vec<_> = choices_iter.map(lit_to_string).collect::>()?; + + quote! { Cow::Borrowed(&[#( ::poise::CommandParameterChoice { + name: Cow::Borrowed(#choices), + localizations: Cow::Borrowed(&[]), + __non_exhaustive: (), + } ),*]) } + } else { + quote! { poise::slash_argument_choices!(#type_) } } - false => quote::quote! { vec![] }, + } else { + quote! { Cow::Borrowed(&[]) } }; let channel_types = match ¶m.args.channel_types { - Some(crate::util::List(channel_types)) => quote::quote! { Some( - vec![ #( poise::serenity_prelude::ChannelType::#channel_types ),* ] + Some(crate::util::List(channel_types)) => quote! { Some( + Cow::Borrowed(&[ #( poise::serenity_prelude::ChannelType::#channel_types ),* ]) ) }, - None => quote::quote! { None }, + None => quote! { None }, }; parameter_structs.push(( - quote::quote! { + quote! { ::poise::CommandParameter { - name: #param_name.to_string(), + name: ::std::borrow::Cow::Borrowed(#param_name), name_localizations: #name_localizations, description: #description, description_localizations: #desc_localizations, required: #required, channel_types: #channel_types, + value_limits: #value_limits, type_setter: #type_setter, choices: #choices, autocomplete_callback: #autocomplete_callback, @@ -163,25 +180,25 @@ pub fn generate_slash_action(inv: &Invocation) -> Result>(); - Ok(quote::quote! { + Ok(quote! { |ctx| Box::pin(async move { // idk why this can't be put in the macro itself (where the lint is triggered) and // why clippy doesn't turn off this lint inside macros in the first place #[allow(clippy::needless_question_mark)] let ( #( #param_identifiers, )* ) = ::poise::parse_slash_args!( - ctx.serenity_context, ctx.interaction, ctx.args => + ctx.serenity_context(), ctx.interaction, ctx.args => #( (#param_names: #param_types), )* ).await.map_err(|error| error.to_framework_error(ctx))?; @@ -215,7 +232,7 @@ pub fn generate_context_menu_action( } }; - Ok(quote::quote! { + Ok(quote! { <#param_type as ::poise::ContextMenuParameter<_, _>>::to_action(|ctx, value| { Box::pin(async move { let is_framework_cooldown = !ctx.command.manual_cooldowns diff --git a/macros/src/lib.rs b/macros/src/lib.rs index e3585769e195..76158deb785a 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -58,8 +58,6 @@ for example for command-specific help (i.e. `~help command_name`). Escape newlin ## Help-related arguments - `hide_in_help`: Hide this command in help menus -- `help_text_fn`: Path to a string-returning function which is used for command help text instead of documentation comments - - Useful if you have many commands with very similar help messages: you can abstract the common parts into a function ## Edit tracking (prefix only) diff --git a/macros/src/util.rs b/macros/src/util.rs index 5683ec4bef55..8ce2ea87716f 100644 --- a/macros/src/util.rs +++ b/macros/src/util.rs @@ -38,8 +38,8 @@ pub fn wrap_option_and_map( } pub fn wrap_option_to_string(literal: Option) -> syn::Expr { - let to_string_path = quote::quote!(::std::string::ToString::to_string); - wrap_option_and_map(literal, to_string_path) + let cowstr_path = quote::quote!(Cow::Borrowed); + wrap_option_and_map(literal, cowstr_path) } /// Syn Fold to make all lifetimes 'static. Used to access trait items of a type without having its @@ -99,13 +99,13 @@ where .map(|Tuple2(t, v)| Tuple2(t.deref(), v.deref())) } -pub fn iter_tuple_2_to_hash_map(v: I) -> proc_macro2::TokenStream +pub fn iter_tuple_2_to_vec_map(v: I) -> proc_macro2::TokenStream where I: ExactSizeIterator>, T: quote::ToTokens, { if v.len() == 0 { - return quote::quote!(std::collections::HashMap::new()); + return quote::quote!(Cow::Borrowed(&[])); } let (keys, values) = v @@ -114,8 +114,8 @@ where .unzip::<_, _, Vec<_>, Vec<_>>(); quote::quote! { - std::collections::HashMap::from([ - #( (#keys.to_string(), #values.to_string()) ),* + Cow::Borrowed(&[ + #( (Cow::Borrowed(#keys), Cow::Borrowed(#values)) ),* ]) } } diff --git a/src/builtins/help.rs b/src/builtins/help.rs deleted file mode 100644 index 9cc007910fed..000000000000 --- a/src/builtins/help.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Contains the built-in help command and surrounding infrastructure - -use crate::{serenity_prelude as serenity, CreateReply}; -use std::fmt::Write as _; - -/// Optional configuration for how the help message from [`help()`] looks -pub struct HelpConfiguration<'a> { - /// Extra text displayed at the bottom of your message. Can be used for help and tips specific - /// to your bot - pub extra_text_at_bottom: &'a str, - /// Whether to make the response ephemeral if possible. Can be nice to reduce clutter - pub ephemeral: bool, - /// Whether to list context menu commands as well - pub show_context_menu_commands: bool, - /// Whether to list context menu commands as well - pub show_subcommands: bool, - /// Whether to include [`crate::Command::description`] (above [`crate::Command::help_text`]). - pub include_description: bool, - #[doc(hidden)] - pub __non_exhaustive: (), -} - -impl Default for HelpConfiguration<'_> { - fn default() -> Self { - Self { - extra_text_at_bottom: "", - ephemeral: true, - show_context_menu_commands: false, - show_subcommands: false, - include_description: true, - __non_exhaustive: (), - } - } -} - -/// Convenience function to align descriptions behind commands -struct TwoColumnList(Vec<(String, Option)>); - -impl TwoColumnList { - /// Creates a new [`TwoColumnList`] - fn new() -> Self { - Self(Vec::new()) - } - - /// Add a line that needs the padding between the columns - fn push_two_colums(&mut self, command: String, description: String) { - self.0.push((command, Some(description))); - } - - /// Add a line that doesn't influence the first columns's width - fn push_heading(&mut self, category: &str) { - if !self.0.is_empty() { - self.0.push(("".to_string(), None)); - } - let mut category = category.to_string(); - category += ":"; - self.0.push((category, None)); - } - - /// Convert the list into a string with aligned descriptions - fn into_string(self) -> String { - let longest_command = self - .0 - .iter() - .filter_map(|(command, description)| { - if description.is_some() { - Some(command.len()) - } else { - None - } - }) - .max() - .unwrap_or(0); - let mut text = String::new(); - for (command, description) in self.0 { - if let Some(description) = description { - let padding = " ".repeat(longest_command - command.len() + 3); - writeln!(text, "{}{}{}", command, padding, description).unwrap(); - } else { - writeln!(text, "{}", command).unwrap(); - } - } - text - } -} - -/// Get the prefix from options -pub(super) async fn get_prefix_from_options(ctx: crate::Context<'_, U, E>) -> Option { - let options = &ctx.framework().options().prefix_options; - match &options.prefix { - Some(fixed_prefix) => Some(fixed_prefix.clone()), - None => match options.dynamic_prefix { - Some(dynamic_prefix_callback) => { - match dynamic_prefix_callback(crate::PartialContext::from(ctx)).await { - Ok(Some(dynamic_prefix)) => Some(dynamic_prefix), - _ => None, - } - } - None => None, - }, - } -} - -/// Format context menu command name -fn format_context_menu_name(command: &crate::Command) -> Option { - let kind = match command.context_menu_action { - Some(crate::ContextMenuCommandAction::User(_)) => "user", - Some(crate::ContextMenuCommandAction::Message(_)) => "message", - Some(crate::ContextMenuCommandAction::__NonExhaustive) => unreachable!(), - None => return None, - }; - Some(format!( - "{} (on {})", - command - .context_menu_name - .as_deref() - .unwrap_or(&command.name), - kind - )) -} - -/// Code for printing help of a specific command (e.g. `~help my_command`) -async fn help_single_command( - ctx: crate::Context<'_, U, E>, - command_name: &str, - config: HelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - let commands = &ctx.framework().options().commands; - // Try interpret the command name as a context menu command first - let mut command = commands.iter().find(|command| { - if let Some(context_menu_name) = &command.context_menu_name { - if context_menu_name.eq_ignore_ascii_case(command_name) { - return true; - } - } - false - }); - // Then interpret command name as a normal command (possibly nested subcommand) - if command.is_none() { - if let Some((c, _, _)) = crate::find_command(commands, command_name, true, &mut vec![]) { - command = Some(c); - } - } - - let reply = if let Some(command) = command { - let mut invocations = Vec::new(); - let mut subprefix = None; - if command.slash_action.is_some() { - invocations.push(format!("`/{}`", command.name)); - subprefix = Some(format!(" /{}", command.name)); - } - if command.prefix_action.is_some() { - let prefix = match get_prefix_from_options(ctx).await { - Some(prefix) => prefix, - // None can happen if the prefix is dynamic, and the callback - // fails due to help being invoked with slash or context menu - // commands. Not sure there's a better way to handle this. - None => String::from(""), - }; - invocations.push(format!("`{}{}`", prefix, command.name)); - if subprefix.is_none() { - subprefix = Some(format!(" {}{}", prefix, command.name)); - } - } - if command.context_menu_name.is_some() && command.context_menu_action.is_some() { - // Since command.context_menu_action is Some, this unwrap is safe - invocations.push(format_context_menu_name(command).unwrap()); - if subprefix.is_none() { - subprefix = Some(String::from(" ")); - } - } - // At least one of the three if blocks should have triggered - assert!(subprefix.is_some()); - assert!(!invocations.is_empty()); - let invocations = invocations.join("\n"); - - let mut text = match (&command.description, &command.help_text) { - (Some(description), Some(help_text)) => { - if config.include_description { - format!("{}\n\n{}", description, help_text) - } else { - help_text.clone() - } - } - (Some(description), None) => description.to_owned(), - (None, Some(help_text)) => help_text.clone(), - (None, None) => "No help available".to_string(), - }; - if !command.parameters.is_empty() { - text += "\n\n```\nParameters:\n"; - let mut parameterlist = TwoColumnList::new(); - for parameter in &command.parameters { - let name = parameter.name.clone(); - let description = parameter.description.as_deref().unwrap_or(""); - let description = format!( - "({}) {}", - if parameter.required { - "required" - } else { - "optional" - }, - description, - ); - parameterlist.push_two_colums(name, description); - } - text += ¶meterlist.into_string(); - text += "```"; - } - if !command.subcommands.is_empty() { - text += "\n\n```\nSubcommands:\n"; - let mut commandlist = TwoColumnList::new(); - // Subcommands can exist on context menu commands, but there's no - // hierarchy in the menu, so just display them as a list without - // subprefix. - preformat_subcommands( - &mut commandlist, - command, - &subprefix.unwrap_or_else(|| String::from(" ")), - ); - text += &commandlist.into_string(); - text += "```"; - } - format!("**{}**\n\n{}", invocations, text) - } else { - format!("No such command `{}`", command_name) - }; - - let reply = CreateReply::default() - .content(reply) - .ephemeral(config.ephemeral); - - ctx.send(reply).await?; - Ok(()) -} - -/// Recursively formats all subcommands -fn preformat_subcommands( - commands: &mut TwoColumnList, - command: &crate::Command, - prefix: &str, -) { - let as_context_command = command.slash_action.is_none() && command.prefix_action.is_none(); - for subcommand in &command.subcommands { - let command = if as_context_command { - let name = format_context_menu_name(subcommand); - if name.is_none() { - continue; - }; - name.unwrap() - } else { - format!("{} {}", prefix, subcommand.name) - }; - let description = subcommand.description.as_deref().unwrap_or("").to_string(); - commands.push_two_colums(command, description); - // We could recurse here, but things can get cluttered quickly. - // Instead, we show (using this function) subsubcommands when - // the user asks for help on the subcommand. - } -} - -/// Preformat lines (except for padding,) like `(" /ping", "Emits a ping message")` -fn preformat_command( - commands: &mut TwoColumnList, - config: &HelpConfiguration<'_>, - command: &crate::Command, - indent: &str, - options_prefix: Option<&str>, -) { - let prefix = if command.slash_action.is_some() { - String::from("/") - } else if command.prefix_action.is_some() { - options_prefix.map(String::from).unwrap_or_default() - } else { - // This is not a prefix or slash command, i.e. probably a context menu only command - // This should have been filtered out in `generate_all_commands` - unreachable!(); - }; - - let prefix = format!("{}{}{}", indent, prefix, command.name); - commands.push_two_colums( - prefix.clone(), - command.description.as_deref().unwrap_or("").to_string(), - ); - if config.show_subcommands { - preformat_subcommands(commands, command, &prefix) - } -} - -/// Create help text for `help_all_commands` -/// -/// This is a separate function so we can have tests for it -async fn generate_all_commands( - ctx: crate::Context<'_, U, E>, - config: &HelpConfiguration<'_>, -) -> Result { - let mut categories = indexmap::IndexMap::, Vec<&crate::Command>>::new(); - for cmd in &ctx.framework().options().commands { - categories - .entry(cmd.category.as_deref()) - .or_default() - .push(cmd); - } - - let options_prefix = get_prefix_from_options(ctx).await; - - let mut menu = String::from("```\n"); - - let mut commandlist = TwoColumnList::new(); - for (category_name, commands) in categories { - let commands = commands - .into_iter() - .filter(|cmd| { - !cmd.hide_in_help && (cmd.prefix_action.is_some() || cmd.slash_action.is_some()) - }) - .collect::>(); - if commands.is_empty() { - continue; - } - commandlist.push_heading(category_name.unwrap_or("Commands")); - for command in commands { - preformat_command( - &mut commandlist, - config, - command, - " ", - options_prefix.as_deref(), - ); - } - } - menu += &commandlist.into_string(); - - if config.show_context_menu_commands { - menu += "\nContext menu commands:\n"; - - for command in &ctx.framework().options().commands { - let name = format_context_menu_name(command); - if name.is_none() { - continue; - }; - let _ = writeln!(menu, " {}", name.unwrap()); - } - } - - menu += "\n"; - menu += config.extra_text_at_bottom; - menu += "\n```"; - - Ok(menu) -} - -/// Code for printing an overview of all commands (e.g. `~help`) -async fn help_all_commands( - ctx: crate::Context<'_, U, E>, - config: HelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - let menu = generate_all_commands(ctx, &config).await?; - let reply = CreateReply::default() - .content(menu) - .ephemeral(config.ephemeral); - - ctx.send(reply).await?; - Ok(()) -} - -/// A help command that outputs text in a code block, groups commands by categories, and annotates -/// commands with a slash if they exist as slash commands. -/// -/// Example usage from Ferris, the Discord bot running in the Rust community server: -/// ```rust -/// # type Error = Box; -/// # type Context<'a> = poise::Context<'a, (), Error>; -/// /// Show this menu -/// #[poise::command(prefix_command, track_edits, slash_command)] -/// pub async fn help( -/// ctx: Context<'_>, -/// #[description = "Specific command to show help about"] command: Option, -/// ) -> Result<(), Error> { -/// let config = poise::builtins::HelpConfiguration { -/// extra_text_at_bottom: "\ -/// Type ?help command for more info on a command. -/// You can edit your message to the bot and the bot will edit its response.", -/// ..Default::default() -/// }; -/// poise::builtins::help(ctx, command.as_deref(), config).await?; -/// Ok(()) -/// } -/// ``` -/// Output: -/// ```text -/// Playground: -/// ?play Compile and run Rust code in a playground -/// ?eval Evaluate a single Rust expression -/// ?miri Run code and detect undefined behavior using Miri -/// ?expand Expand macros to their raw desugared form -/// ?clippy Catch common mistakes using the Clippy linter -/// ?fmt Format code using rustfmt -/// ?microbench Benchmark small snippets of code -/// ?procmacro Compile and use a procedural macro -/// ?godbolt View assembly using Godbolt -/// ?mca Run performance analysis using llvm-mca -/// ?llvmir View LLVM IR using Godbolt -/// Crates: -/// /crate Lookup crates on crates.io -/// /doc Lookup documentation -/// Moderation: -/// /cleanup Deletes the bot's messages for cleanup -/// /ban Bans another person -/// ?move Move a discussion to another channel -/// /rustify Adds the Rustacean role to members -/// Miscellaneous: -/// ?go Evaluates Go code -/// /source Links to the bot GitHub repo -/// /help Show this menu -/// -/// Type ?help command for more info on a command. -/// You can edit your message to the bot and the bot will edit its response. -/// ``` -pub async fn help( - ctx: crate::Context<'_, U, E>, - command: Option<&str>, - config: HelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - match command { - Some(command) => help_single_command(ctx, command, config).await, - None => help_all_commands(ctx, config).await, - } -} diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index 45e8082563f6..80c801eda93f 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -1,14 +1,8 @@ -//! Building blocks for common commands like help commands or application command registration +//! Building blocks for common commands //! -//! This file provides sample commands and utility functions like help menus or error handlers to +//! This file provides sample commands and utility functions like pagination or error handlers to //! use as a starting point for the framework. -mod help; -pub use help::*; - -mod pretty_help; -pub use pretty_help::*; - mod register; pub use register::*; @@ -173,6 +167,10 @@ pub async fn on_error( ctx.send(CreateReply::default().content(response).ephemeral(true)) .await?; } + crate::FrameworkError::PermissionFetchFailed { ctx } => { + ctx.say("An error occurred when fetching permissions.") + .await?; + } crate::FrameworkError::NotAnOwner { ctx } => { let response = "Only bot owners can call this command"; ctx.send(CreateReply::default().content(response).ephemeral(true)) @@ -230,13 +228,17 @@ pub async fn on_error( pub async fn autocomplete_command<'a, U, E>( ctx: crate::Context<'a, U, E>, partial: &'a str, -) -> impl Iterator + 'a { - ctx.framework() - .options() - .commands - .iter() - .filter(move |cmd| cmd.name.starts_with(partial)) - .map(|cmd| cmd.name.to_string()) +) -> serenity::CreateAutocompleteResponse { + let commands = ctx.framework().options.commands.iter(); + let filtered_commands = commands + .filter(|cmd| cmd.name.starts_with(partial)) + .take(25); + + let choices = filtered_commands + .map(|cmd| serenity::AutocompleteChoice::from(cmd.name.as_ref())) + .collect(); + + serenity::CreateAutocompleteResponse::new().set_choices(choices) } /// Lists servers of which the bot is a member of, including their member counts, sorted diff --git a/src/builtins/pretty_help.rs b/src/builtins/pretty_help.rs deleted file mode 100644 index f3a8a339466d..000000000000 --- a/src/builtins/pretty_help.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Contains a built-in help command and surrounding infrastructure that uses embeds. - -use crate::{serenity_prelude as serenity, CreateReply}; -use std::fmt::Write as _; - -/// Optional configuration for how the help message from [`pretty_help()`] looks -pub struct PrettyHelpConfiguration<'a> { - /// Extra text displayed at the bottom of your message. Can be used for help and tips specific - /// to your bot - pub extra_text_at_bottom: &'a str, - /// Whether to make the response ephemeral if possible. Can be nice to reduce clutter - pub ephemeral: bool, - /// Whether to list context menu commands as well - pub show_context_menu_commands: bool, - /// Whether to list context menu commands as well - pub show_subcommands: bool, - /// Whether to include [`crate::Command::description`] (above [`crate::Command::help_text`]). - pub include_description: bool, - /// Color of the Embed - pub color: (u8, u8, u8), - #[doc(hidden)] - pub __non_exhaustive: (), -} - -impl Default for PrettyHelpConfiguration<'_> { - fn default() -> Self { - Self { - extra_text_at_bottom: "", - ephemeral: true, - show_context_menu_commands: false, - show_subcommands: false, - include_description: true, - color: (0, 110, 51), - __non_exhaustive: (), - } - } -} - -/// A help command that works similarly to `builtin::help` but outputs text in an embed. -/// -pub async fn pretty_help( - ctx: crate::Context<'_, U, E>, - command: Option<&str>, - config: PrettyHelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - match command { - Some(command) => pretty_help_single_command(ctx, command, config).await, - None => pretty_help_all_commands(ctx, config).await, - } -} - -/// Printing an overview of all commands (e.g. `~help`) -async fn pretty_help_all_commands( - ctx: crate::Context<'_, U, E>, - config: PrettyHelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - let commands = ctx.framework().options().commands.iter().filter(|cmd| { - !cmd.hide_in_help - && (cmd.prefix_action.is_some() - || cmd.slash_action.is_some() - || (cmd.context_menu_action.is_some() && config.show_context_menu_commands)) - }); - - let mut categories = indexmap::IndexMap::, Vec<&crate::Command>>::new(); - for cmd in commands { - categories - .entry(cmd.category.as_deref()) - .or_default() - .push(cmd); - } - - let options_prefix = super::help::get_prefix_from_options(ctx).await; - - let fields = categories - .into_iter() - .filter(|(_, cmds)| !cmds.is_empty()) - .map(|(category, mut cmds)| { - // get context menu items at the bottom - cmds.sort_by_key(|cmd| cmd.slash_action.is_none() && cmd.prefix_action.is_none()); - - let mut buffer = String::new(); - - for cmd in cmds { - let name = cmd.context_menu_name.as_deref().unwrap_or(&cmd.name); - let prefix = format_cmd_prefix(cmd, &options_prefix); - - if let Some(description) = cmd.description.as_deref() { - writeln!(buffer, "{}{}`: *{}*", prefix, name, description).ok(); - } else { - writeln!(buffer, "{}{}`.", prefix, name).ok(); - } - - if config.show_subcommands { - for sbcmd in &cmd.subcommands { - let name = sbcmd.context_menu_name.as_deref().unwrap_or(&sbcmd.name); - let prefix = format_cmd_prefix(sbcmd, &options_prefix); - - if let Some(description) = sbcmd.description.as_deref() { - writeln!(buffer, "> {}{}`: *{}*", prefix, name, description).ok(); - } else { - writeln!(buffer, "> {}{}`.", prefix, name).ok(); - } - } - } - } - if let Some((i, _)) = buffer.char_indices().nth(1024) { - buffer.truncate(i); - } - (category.unwrap_or_default(), buffer, false) - }) - .collect::>(); - - let embed = serenity::CreateEmbed::new() - .title("Help") - .fields(fields) - .color(config.color) - .footer(serenity::CreateEmbedFooter::new( - config.extra_text_at_bottom, - )); - - let reply = crate::CreateReply::default() - .embed(embed) - .ephemeral(config.ephemeral); - - ctx.send(reply).await?; - - Ok(()) -} - -/// Figures out which prefix a command should have -fn format_cmd_prefix(cmd: &crate::Command, options_prefix: &Option) -> String { - if cmd.slash_action.is_some() { - "`/".into() - } else if cmd.prefix_action.is_some() { - format!("`{}", options_prefix.as_deref().unwrap_or_default()) - } else if cmd.context_menu_action.is_some() { - match cmd.context_menu_action { - Some(crate::ContextMenuCommandAction::Message(_)) => "Message menu: `".into(), - Some(crate::ContextMenuCommandAction::User(_)) => "User menu: `".into(), - Some(crate::ContextMenuCommandAction::__NonExhaustive) | None => { - unreachable!() - } - } - } else { - "`".into() - } -} - -/// Code for printing help of a specific command (e.g. `~help my_command`) -async fn pretty_help_single_command( - ctx: crate::Context<'_, U, E>, - command_name: &str, - config: PrettyHelpConfiguration<'_>, -) -> Result<(), serenity::Error> { - let commands = &ctx.framework().options().commands; - - // Try interpret the command name as a context menu command first - let command = commands - .iter() - .find(|cmd| { - cmd.context_menu_name - .as_ref() - .is_some_and(|n| n.eq_ignore_ascii_case(command_name)) - }) - // Then interpret command name as a normal command (possibly nested subcommand) - .or(crate::find_command(commands, command_name, true, &mut vec![]).map(|(c, _, _)| c)); - - let Some(command) = command else { - ctx.send( - CreateReply::default() - .content(format!("No such command `{}`", command_name)) - .ephemeral(config.ephemeral), - ) - .await?; - - return Ok(()); - }; - - let mut invocations = Vec::new(); - let mut subprefix = None; - - if command.slash_action.is_some() { - invocations.push(format!("`/{}`", command.name)); - subprefix = Some(format!("> `/{}`", command.name)); - } - if command.prefix_action.is_some() { - let prefix = super::help::get_prefix_from_options(ctx) - .await - // This can happen if the prefix is dynamic, and the callback fails - // due to help being invoked with slash or context menu commands. - .unwrap_or_else(|| String::from("")); - invocations.push(format!("`{}{}`", prefix, command.name)); - subprefix = subprefix.or(Some(format!("> `{}{}`", prefix, command.name))); - } - if command.context_menu_name.is_some() && command.context_menu_action.is_some() { - let kind = match command.context_menu_action { - Some(crate::ContextMenuCommandAction::User(_)) => "user", - Some(crate::ContextMenuCommandAction::Message(_)) => "message", - Some(crate::ContextMenuCommandAction::__NonExhaustive) | None => unreachable!(), - }; - invocations.push(format!( - "`{}` (on {})", - command - .context_menu_name - .as_deref() - .unwrap_or(&command.name), - kind - )); - subprefix = subprefix.or(Some(String::from("> "))); - } - // At least one of the three if blocks should have triggered - assert!(!invocations.is_empty()); - assert!(subprefix.is_some()); - - let invocations = invocations - .into_iter() - .reduce(|x, y| format!("{x}\n{y}")) - .map(|s| ("", s, false)); - - let description = match (&command.description, &command.help_text) { - (Some(description), Some(help_text)) if config.include_description => { - format!("{}\n\n{}", description, help_text) - } - (_, Some(help_text)) => help_text.clone(), - (Some(description), None) => description.clone(), - (None, None) => "No help available".to_string(), - }; - - let parameters = command - .parameters - .iter() - .map(|parameter| { - let req = if parameter.required { - "required" - } else { - "optional" - }; - if let Some(description) = parameter.description.as_deref() { - format!("`{}` ({}) *{} *.", parameter.name, req, description) - } else { - format!("`{}` ({}).", parameter.name, req) - } - }) - .reduce(|x, y| format!("{x}\n{y}")) - .map(|s| ("Parameters", s, false)); - - let sbcmds = command - .subcommands - .iter() - .map(|sbcmd| { - let prefix = format_cmd_prefix(sbcmd, &subprefix); // i have no idea about this really - let name = sbcmd.context_menu_name.as_deref().unwrap_or(&sbcmd.name); - if let Some(description) = sbcmd.description.as_deref() { - format!("> {}{}`: *{} *", prefix, name, description) - } else { - format!("> {}{}`", prefix, name,) - } - }) - .reduce(|x, y| format!("{x}\n{y}")) - .map(|s| ("Subcommands", s, false)); - - let fields = invocations - .into_iter() - .chain(parameters.into_iter()) - .chain(sbcmds.into_iter()); - - let embed = serenity::CreateEmbed::default() - .description(description) - .fields(fields); - - let reply = CreateReply::default() - .embed(embed) - .ephemeral(config.ephemeral); - - ctx.send(reply).await?; - Ok(()) -} diff --git a/src/choice_parameter.rs b/src/choice_parameter.rs index 94b7753494a6..32f0b168cac2 100644 --- a/src/choice_parameter.rs +++ b/src/choice_parameter.rs @@ -1,13 +1,13 @@ //! Contains the [`ChoiceParameter`] trait and the blanket [`crate::SlashArgument`] and //! [`crate::PopArgument`] impl -use crate::serenity_prelude as serenity; +use crate::{serenity_prelude as serenity, CowVec}; /// This trait is implemented by [`crate::macros::ChoiceParameter`]. See its docs for more /// information pub trait ChoiceParameter: Sized { /// Returns all possible choices for this parameter, in the order they will appear in Discord. - fn list() -> Vec; + fn list() -> CowVec; /// Returns an instance of [`Self`] corresponding to the given index into [`Self::list()`] fn from_index(index: usize) -> Option; @@ -50,7 +50,7 @@ impl crate::SlashArgument for T { builder.kind(serenity::CommandOptionType::Integer) } - fn choices() -> Vec { + fn choices() -> CowVec { Self::list() } } diff --git a/src/dispatch/common.rs b/src/dispatch/common.rs index ac4944832802..ca26c38a8aa8 100644 --- a/src/dispatch/common.rs +++ b/src/dispatch/common.rs @@ -2,60 +2,6 @@ use crate::serenity_prelude as serenity; -/// Retrieves user permissions in the given channel. If unknown, returns None. If in DMs, returns -/// `Permissions::all()`. -async fn user_permissions( - ctx: &serenity::Context, - guild_id: Option, - channel_id: serenity::ChannelId, - user_id: serenity::UserId, -) -> Option { - let guild_id = match guild_id { - Some(x) => x, - None => return Some(serenity::Permissions::all()), // no permission checks in DMs - }; - - let guild = guild_id.to_partial_guild(ctx).await.ok()?; - - // Use to_channel so that it can fallback on HTTP for threads (which aren't in cache usually) - let channel = match channel_id.to_channel(ctx).await { - Ok(serenity::Channel::Guild(channel)) => channel, - Ok(_other_channel) => { - tracing::warn!( - "guild message was supposedly sent in a non-guild channel. Denying invocation" - ); - return None; - } - Err(_) => return None, - }; - - let member = guild.member(ctx, user_id).await.ok()?; - - Some(guild.user_permissions_in(&channel, &member)) -} - -/// Retrieves the set of permissions that are lacking, relative to the given required permission set -/// -/// Returns None if permissions couldn't be retrieved -async fn missing_permissions( - ctx: crate::Context<'_, U, E>, - user: serenity::UserId, - required_permissions: serenity::Permissions, -) -> Option { - if required_permissions.is_empty() { - return Some(serenity::Permissions::empty()); - } - - let permissions = user_permissions( - ctx.serenity_context(), - ctx.guild_id(), - ctx.channel_id(), - user, - ) - .await; - Some(required_permissions - permissions?) -} - /// See [`check_permissions_and_cooldown`]. Runs the check only for a single command. The caller /// should call this multiple time for each parent command to achieve the check inheritance logic. async fn check_permissions_and_cooldown_single<'a, U, E>( @@ -111,35 +57,29 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( } // Make sure that user has required permissions - match missing_permissions(ctx, ctx.author().id, cmd.required_permissions).await { - Some(missing_permissions) if missing_permissions.is_empty() => {} - Some(missing_permissions) => { - return Err(crate::FrameworkError::MissingUserPermissions { - ctx, - missing_permissions: Some(missing_permissions), - }) - } - // Better safe than sorry: when perms are unknown, restrict access - None => { + if let Some((user_missing_permissions, bot_missing_permissions)) = + super::permissions::calculate_missing( + ctx, + cmd.required_permissions, + cmd.required_bot_permissions, + ) + .await + { + if !user_missing_permissions.is_empty() { return Err(crate::FrameworkError::MissingUserPermissions { ctx, - missing_permissions: None, - }) + missing_permissions: Some(user_missing_permissions), + }); } - } - // Before running any pre-command checks, make sure the bot has the permissions it needs - match missing_permissions(ctx, ctx.framework().bot_id, cmd.required_bot_permissions).await { - Some(missing_permissions) if missing_permissions.is_empty() => {} - Some(missing_permissions) => { + if !bot_missing_permissions.is_empty() { return Err(crate::FrameworkError::MissingBotPermissions { ctx, - missing_permissions, - }) + missing_permissions: bot_missing_permissions, + }); } - // When in doubt, just let it run. Not getting fancy missing permissions errors is better - // than the command not executing at all - None => {} + } else { + return Err(crate::FrameworkError::PermissionFetchFailed { ctx }); } // Only continue if command checks returns true diff --git a/src/dispatch/mod.rs b/src/dispatch/mod.rs index 38c29fe8e793..07bbb5b4761a 100644 --- a/src/dispatch/mod.rs +++ b/src/dispatch/mod.rs @@ -1,6 +1,7 @@ //! Contains all code to dispatch incoming events onto framework commands mod common; +mod permissions; mod prefix; mod slash; @@ -10,11 +11,12 @@ pub use slash::*; use crate::serenity_prelude as serenity; -// TODO: integrate serenity::Context in here? Every place where FrameworkContext is passed is also -// passed serenity::Context /// A view into data stored by [`crate::Framework`] pub struct FrameworkContext<'a, U, E> { - /// User ID of this bot + /// Serenity's context + pub serenity_context: &'a serenity::Context, + /// User ID of this bot, available through serenity_context if cache is enabled. + #[cfg(not(feature = "cache"))] pub bot_id: serenity::UserId, /// Framework configuration pub options: &'a crate::FrameworkOptions, @@ -32,6 +34,16 @@ impl Clone for FrameworkContext<'_, U, E> { } } impl<'a, U, E> FrameworkContext<'a, U, E> { + /// Returns the user ID of the bot. + pub fn bot_id(&self) -> serenity::UserId { + #[cfg(feature = "cache")] + let bot_id = self.serenity_context.cache.current_user().id; + #[cfg(not(feature = "cache"))] + let bot_id = self.bot_id; + + bot_id + } + /// Returns the stored framework options, including commands. /// /// This function exists for API compatiblity with [`crate::Framework`]. On this type, you can @@ -61,7 +73,6 @@ impl<'a, U, E> FrameworkContext<'a, U, E> { /// Central event handling function of this library pub async fn dispatch_event( framework: crate::FrameworkContext<'_, U, E>, - ctx: &serenity::Context, event: serenity::FullEvent, ) { match &event { @@ -71,7 +82,6 @@ pub async fn dispatch_event( let trigger = crate::MessageDispatchTrigger::MessageCreate; if let Err(error) = prefix::dispatch_message( framework, - ctx, new_message, trigger, &invocation_data, @@ -101,7 +111,6 @@ pub async fn dispatch_event( }; if let Err(error) = prefix::dispatch_message( framework, - ctx, &msg, trigger, &invocation_data, @@ -123,7 +132,7 @@ pub async fn dispatch_event( .unwrap() .process_message_delete(*deleted_message_id); if let Some(bot_response) = bot_response { - if let Err(e) = bot_response.delete(ctx).await { + if let Err(e) = bot_response.delete(framework.serenity_context).await { tracing::warn!("failed to delete bot response: {}", e); } } @@ -136,7 +145,6 @@ pub async fn dispatch_event( let mut parent_commands = Vec::new(); if let Err(error) = slash::dispatch_interaction( framework, - ctx, interaction, &std::sync::atomic::AtomicBool::new(false), &invocation_data, @@ -155,7 +163,6 @@ pub async fn dispatch_event( let mut parent_commands = Vec::new(); if let Err(error) = slash::dispatch_autocomplete( framework, - ctx, interaction, &std::sync::atomic::AtomicBool::new(false), &invocation_data, @@ -172,12 +179,9 @@ pub async fn dispatch_event( // Do this after the framework's Ready handling, so that get_user_data() doesnt // potentially block infinitely - if let Err(error) = - (framework.options.event_handler)(ctx, &event, framework, framework.user_data).await - { + if let Err(error) = (framework.options.event_handler)(framework, &event).await { let error = crate::FrameworkError::EventHandler { error, - ctx, event: &event, framework, }; diff --git a/src/dispatch/permissions.rs b/src/dispatch/permissions.rs new file mode 100644 index 000000000000..40dd29b0fb69 --- /dev/null +++ b/src/dispatch/permissions.rs @@ -0,0 +1,82 @@ +//! Module for calculating permission checks for commands + +use crate::serenity_prelude as serenity; + +mod application; +mod prefix; + +/// Simple POD type to hold the results of permission lookups. +struct PermissionsInfo { + /// The Permissions of the author, if requested. + author_permissions: Option, + /// The Permissions of the bot, if requested. + bot_permissions: Option, +} + +impl PermissionsInfo { + /// Returns the fake permissions info from a DM. + fn dm_permissions() -> Self { + Self { + author_permissions: Some(serenity::Permissions::dm_permissions()), + bot_permissions: Some(serenity::Permissions::dm_permissions()), + } + } +} + +/// Retrieves the permissions for the context author and the bot. +async fn get_author_and_bot_permissions( + ctx: crate::Context<'_, U, E>, + skip_author: bool, + skip_bot: bool, +) -> Option { + // No permission checks in DMs. + let Some(guild_id) = ctx.guild_id() else { + return Some(PermissionsInfo::dm_permissions()); + }; + + match ctx { + crate::Context::Application(ctx) => { + Some(application::get_author_and_bot_permissions(ctx.interaction)) + } + crate::Context::Prefix(ctx) => { + prefix::get_author_and_bot_permissions(ctx, guild_id, skip_author, skip_bot).await + } + } +} + +/// Retrieves the set of permissions that are lacking, relative to the given required permission set +/// +/// Returns None if permissions couldn't be retrieved. +pub(super) async fn calculate_missing( + ctx: crate::Context<'_, U, E>, + author_required_permissions: serenity::Permissions, + bot_required_permissions: serenity::Permissions, +) -> Option<(serenity::Permissions, serenity::Permissions)> { + // If both user and bot are None, return empty permissions + if author_required_permissions.is_empty() && bot_required_permissions.is_empty() { + return Some(( + serenity::Permissions::empty(), + serenity::Permissions::empty(), + )); + } + + // Fetch permissions, returning None if an error occurred + let permissions = get_author_and_bot_permissions( + ctx, + author_required_permissions.is_empty(), + bot_required_permissions.is_empty(), + ) + .await?; + + let author_missing_perms = permissions + .author_permissions + .map(|permissions| author_required_permissions - permissions) + .unwrap_or_default(); + + let bot_missing_perms = permissions + .bot_permissions + .map(|permissions| bot_required_permissions - permissions) + .unwrap_or_default(); + + Some((author_missing_perms, bot_missing_perms)) +} diff --git a/src/dispatch/permissions/application.rs b/src/dispatch/permissions/application.rs new file mode 100644 index 000000000000..e892c29c8eec --- /dev/null +++ b/src/dispatch/permissions/application.rs @@ -0,0 +1,23 @@ +//! Application command permissions calculation +use crate::serenity_prelude as serenity; + +use super::PermissionsInfo; + +/// Gets the permissions of the ctx author and the bot. +pub(super) fn get_author_and_bot_permissions( + interaction: &serenity::CommandInteraction, +) -> PermissionsInfo { + let err = "member is Some if interaction is in guild"; + let author_member = interaction.member.as_ref().expect(err); + + let err = "should always be some as inside interaction"; + let author_permissions = author_member.permissions.expect(err); + + let err = "should always be some according to discord docs"; + let bot_permissions = interaction.app_permissions.expect(err); + + PermissionsInfo { + author_permissions: Some(author_permissions), + bot_permissions: Some(bot_permissions), + } +} diff --git a/src/dispatch/permissions/prefix/cache.rs b/src/dispatch/permissions/prefix/cache.rs new file mode 100644 index 000000000000..bae9e8a94bbf --- /dev/null +++ b/src/dispatch/permissions/prefix/cache.rs @@ -0,0 +1,62 @@ +//! The cache variant of prefix permissions calculation + +use crate::{serenity_prelude as serenity, PrefixContext}; + +use crate::dispatch::permissions::PermissionsInfo; + +/// Gets the permissions of the ctx author and the bot. +pub(in crate::dispatch::permissions) async fn get_author_and_bot_permissions( + ctx: PrefixContext<'_, U, E>, + guild_id: serenity::GuildId, + skip_author: bool, + skip_bot: bool, +) -> Option { + // Should only fail if the guild is not cached, which is fair to bail on. + let guild = ctx.cache().guild(guild_id)?; + + let author_permissions = if skip_author { + None + } else { + let err_msg = "should only fail if the guild is not cached"; + Some(ctx.msg.author_permissions(ctx.cache()).expect(err_msg)) + }; + + let bot_permissions = if skip_bot { + None + } else { + let channel_id = ctx.channel_id(); + let bot_user_id = ctx.framework.bot_id(); + Some(get_bot_permissions(&guild, channel_id, bot_user_id)?) + }; + + Some(PermissionsInfo { + author_permissions, + bot_permissions, + }) +} + +/// Gets the permissions for the bot. +fn get_bot_permissions( + guild: &serenity::Guild, + channel_id: serenity::ChannelId, + bot_id: serenity::UserId, +) -> Option { + // Should never fail, as the bot member is always cached + let bot_member = guild.members.get(&bot_id)?; + + if let Some(channel) = guild.channels.get(&channel_id) { + Some(guild.user_permissions_in(channel, bot_member)) + } else if let Some(thread) = guild.threads.iter().find(|th| th.id == channel_id) { + let err = "parent id should always be Some for thread"; + let parent_channel_id = thread.parent_id.expect(err); + + let parent_channel = guild.channels.get(&parent_channel_id)?; + Some(guild.user_permissions_in(parent_channel, bot_member)) + } else { + // The message was either: + // - Sent in a guild with broken caching + // - Not set in a channel or thread? + tracing::warn!("Could not find channel/thread ({channel_id}) for permissions check in cache for guild: {}", guild.id); + None + } +} diff --git a/src/dispatch/permissions/prefix/http.rs b/src/dispatch/permissions/prefix/http.rs new file mode 100644 index 000000000000..17c3b558ff59 --- /dev/null +++ b/src/dispatch/permissions/prefix/http.rs @@ -0,0 +1,40 @@ +//! The cache variant of prefix permissions calculation + +use crate::{serenity_prelude as serenity, PrefixContext}; + +use crate::dispatch::permissions::PermissionsInfo; + +/// Gets the permissions of the ctx author and the bot. +pub(in crate::dispatch::permissions) async fn get_author_and_bot_permissions( + ctx: PrefixContext<'_, U, E>, + guild_id: serenity::GuildId, + skip_author: bool, + skip_bot: bool, +) -> Option { + let http = ctx.http(); + let guild = guild_id.to_partial_guild(http).await.ok()?; + let guild_channel = { + let channel = ctx.http().get_channel(ctx.channel_id()).await.ok()?; + channel.guild().expect("channel should be a guild channel") + }; + + let bot_permissions = if skip_bot { + None + } else { + let bot_member = guild.id.member(http, ctx.framework.bot_id).await.ok()?; + Some(guild.user_permissions_in(&guild_channel, &bot_member)) + }; + + let author_permissions = if skip_author { + None + } else { + let err = "should always be Some in MessageCreateEvent"; + let author_member = ctx.msg.member.as_ref().expect(err); + Some(guild.partial_member_permissions_in(&guild_channel, ctx.author().id, author_member)) + }; + + Some(PermissionsInfo { + author_permissions, + bot_permissions, + }) +} diff --git a/src/dispatch/permissions/prefix/mod.rs b/src/dispatch/permissions/prefix/mod.rs new file mode 100644 index 000000000000..ae0852bd329a --- /dev/null +++ b/src/dispatch/permissions/prefix/mod.rs @@ -0,0 +1,12 @@ +//! Prefix command permissions calculation + +#[cfg(feature = "cache")] +mod cache; +#[cfg(not(feature = "cache"))] +mod http; + +#[cfg(feature = "cache")] +pub(super) use cache::get_author_and_bot_permissions; + +#[cfg(not(feature = "cache"))] +pub(super) use http::get_author_and_bot_permissions; diff --git a/src/dispatch/prefix.rs b/src/dispatch/prefix.rs index cdec95adf63f..1cbc6d8ab78f 100644 --- a/src/dispatch/prefix.rs +++ b/src/dispatch/prefix.rs @@ -7,16 +7,13 @@ use crate::serenity_prelude as serenity; /// Returns tuple of stripped prefix and rest of the message, if any prefix matches async fn strip_prefix<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, msg: &'a serenity::Message, ) -> Option<(&'a str, &'a str)> { let partial_ctx = crate::PartialContext { guild_id: msg.guild_id, channel_id: msg.channel_id, author: &msg.author, - serenity_context: ctx, framework, - data: framework.user_data, __non_exhaustive: (), }; @@ -24,7 +21,7 @@ async fn strip_prefix<'a, U, E>( match dynamic_prefix(partial_ctx).await { Ok(prefix) => { if let Some(prefix) = prefix { - if msg.content.starts_with(&prefix) { + if msg.content.starts_with(prefix.as_ref()) { return Some(msg.content.split_at(prefix.len())); } } @@ -40,7 +37,7 @@ async fn strip_prefix<'a, U, E>( } } - if let Some(prefix) = &framework.options.prefix_options.prefix { + if let Some(prefix) = framework.options.prefix_options.prefix.as_deref() { if let Some(content) = msg.content.strip_prefix(prefix) { return Some((prefix, content)); } @@ -68,7 +65,7 @@ async fn strip_prefix<'a, U, E>( } if let Some(dynamic_prefix) = framework.options.prefix_options.stripped_dynamic_prefix { - match dynamic_prefix(ctx, msg, framework.user_data).await { + match dynamic_prefix(framework.serenity_context, msg, framework.user_data).await { Ok(result) => { if let Some((prefix, content)) = result { return Some((prefix, content)); @@ -91,7 +88,7 @@ async fn strip_prefix<'a, U, E>( msg.content .strip_prefix("<@")? .trim_start_matches('!') - .strip_prefix(&framework.bot_id.to_string())? + .strip_prefix(&framework.bot_id().to_string())? .strip_prefix('>') })() { let mention_prefix = &msg.content[..(msg.content.len() - stripped_content.len())]; @@ -186,21 +183,13 @@ pub fn find_command<'a, U, E>( /// Manually dispatches a message with the prefix framework pub async fn dispatch_message<'a, U: Send + Sync, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, msg: &'a serenity::Message, trigger: crate::MessageDispatchTrigger, invocation_data: &'a tokio::sync::Mutex>, parent_commands: &'a mut Vec<&'a crate::Command>, ) -> Result<(), crate::FrameworkError<'a, U, E>> { - if let Some(ctx) = parse_invocation( - framework, - ctx, - msg, - trigger, - invocation_data, - parent_commands, - ) - .await? + if let Some(ctx) = + parse_invocation(framework, msg, trigger, invocation_data, parent_commands).await? { crate::catch_unwind_maybe(run_invocation(ctx)) .await @@ -209,14 +198,13 @@ pub async fn dispatch_message<'a, U: Send + Sync, E>( ctx: ctx.into(), })??; } else if let Some(non_command_message) = framework.options.prefix_options.non_command_message { - non_command_message(&framework, ctx, msg) - .await - .map_err(|e| crate::FrameworkError::NonCommandMessage { + non_command_message(&framework, msg).await.map_err(|e| { + crate::FrameworkError::NonCommandMessage { error: e, - ctx, framework, msg, - })?; + } + })?; } Ok(()) } @@ -229,7 +217,6 @@ pub async fn dispatch_message<'a, U: Send + Sync, E>( /// fully parsed. pub async fn parse_invocation<'a, U: Send + Sync, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, msg: &'a serenity::Message, trigger: crate::MessageDispatchTrigger, invocation_data: &'a tokio::sync::Mutex>, @@ -241,7 +228,8 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( } // Check if we're allowed to execute our own messages - if framework.bot_id == msg.author.id && !framework.options.prefix_options.execute_self_messages + if framework.bot_id() == msg.author.id + && !framework.options.prefix_options.execute_self_messages { return Ok(None); } @@ -254,7 +242,7 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( } // Strip prefix, trim whitespace between prefix and rest, split rest into command name and args - let (prefix, msg_content) = match strip_prefix(framework, ctx, msg).await { + let (prefix, msg_content) = match strip_prefix(framework, msg).await { Some(x) => x, None => return Ok(None), }; @@ -267,7 +255,6 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( parent_commands, ) .ok_or(crate::FrameworkError::UnknownCommand { - ctx, msg, prefix, msg_content, @@ -283,13 +270,11 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( }; Ok(Some(crate::PrefixContext { - serenity_context: ctx, msg, prefix, invoked_command_name, args, framework, - data: framework.user_data, parent_commands, command, invocation_data, @@ -326,7 +311,11 @@ pub async fn run_invocation( // Typing is broadcasted as long as this object is alive let _typing_broadcaster = if ctx.command.broadcast_typing { - Some(ctx.msg.channel_id.start_typing(&ctx.serenity_context.http)) + Some( + ctx.msg + .channel_id + .start_typing(&ctx.framework.serenity_context.http), + ) } else { None }; diff --git a/src/dispatch/slash.rs b/src/dispatch/slash.rs index dd20995f20be..7db8e7ceb261 100644 --- a/src/dispatch/slash.rs +++ b/src/dispatch/slash.rs @@ -40,7 +40,6 @@ fn find_matching_command<'a, 'b, U, E>( #[allow(clippy::too_many_arguments)] // We need to pass them all in to create Context. fn extract_command<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, interaction: &'a serenity::CommandInteraction, interaction_type: crate::CommandInteractionType, has_sent_initial_response: &'a std::sync::atomic::AtomicBool, @@ -56,14 +55,11 @@ fn extract_command<'a, U, E>( ); let (command, leaf_interaction_options) = search_result.ok_or(crate::FrameworkError::UnknownInteraction { - ctx, framework, interaction, })?; Ok(crate::ApplicationContext { - data: framework.user_data, - serenity_context: ctx, framework, interaction, interaction_type, @@ -80,7 +76,6 @@ fn extract_command<'a, U, E>( #[allow(clippy::too_many_arguments)] // We need to pass them all in to create Context. pub async fn extract_command_and_run_checks<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, interaction: &'a serenity::CommandInteraction, interaction_type: crate::CommandInteractionType, has_sent_initial_response: &'a std::sync::atomic::AtomicBool, @@ -90,7 +85,6 @@ pub async fn extract_command_and_run_checks<'a, U, E>( ) -> Result, crate::FrameworkError<'a, U, E>> { let ctx = extract_command( framework, - ctx, interaction, interaction_type, has_sent_initial_response, @@ -165,7 +159,6 @@ async fn run_command( /// Dispatches this interaction onto framework commands, i.e. runs the associated command pub async fn dispatch_interaction<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, interaction: &'a serenity::CommandInteraction, // Need to pass this in from outside because of lifetime issues has_sent_initial_response: &'a std::sync::atomic::AtomicBool, @@ -177,7 +170,6 @@ pub async fn dispatch_interaction<'a, U, E>( ) -> Result<(), crate::FrameworkError<'a, U, E>> { let ctx = extract_command( framework, - ctx, interaction, crate::CommandInteractionType::Command, has_sent_initial_response, @@ -235,19 +227,13 @@ async fn run_autocomplete( use ::serenity::json::*; // as_str() access via trait for simd-json // Generate an autocomplete response - let autocomplete_response = match autocomplete_callback(ctx, partial_input).await { - Ok(x) => x, - Err(e) => { - tracing::warn!("couldn't generate autocomplete response: {e}"); - return Ok(()); - } - }; + let autocomplete_response = autocomplete_callback(ctx, partial_input).await; // Send the generates autocomplete response if let Err(e) = ctx .interaction .create_response( - &ctx.serenity_context, + &ctx.framework.serenity_context, serenity::CreateInteractionResponse::Autocomplete(autocomplete_response), ) .await @@ -262,7 +248,6 @@ async fn run_autocomplete( /// callback pub async fn dispatch_autocomplete<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, - ctx: &'a serenity::Context, interaction: &'a serenity::CommandInteraction, // Need to pass the following in from outside because of lifetime issues has_sent_initial_response: &'a std::sync::atomic::AtomicBool, @@ -272,7 +257,6 @@ pub async fn dispatch_autocomplete<'a, U, E>( ) -> Result<(), crate::FrameworkError<'a, U, E>> { let ctx = extract_command( framework, - ctx, interaction, crate::CommandInteractionType::Autocomplete, has_sent_initial_response, diff --git a/src/framework/mod.rs b/src/framework/mod.rs index 9da47011751e..6c6161b19d94 100644 --- a/src/framework/mod.rs +++ b/src/framework/mod.rs @@ -1,6 +1,6 @@ //! The central Framework struct that ties everything together. -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; pub use builder::*; @@ -197,17 +197,20 @@ async fn raw_dispatch_event( } let user_data = framework.user_data().await; + #[cfg(not(feature = "cache"))] let bot_id = *framework .bot_id .get() .expect("bot ID not set even though we awaited Ready"); let framework = crate::FrameworkContext { + #[cfg(not(feature = "cache"))] bot_id, + serenity_context: &ctx, options: &framework.options, user_data, shard_manager: framework.shard_manager(), }; - crate::dispatch_event(framework, &ctx, event).await; + crate::dispatch_event(framework, event).await; } /// Traverses commands recursively and sets [`crate::Command::qualified_name`] to its actual value @@ -215,7 +218,7 @@ pub fn set_qualified_names(commands: &mut [crate::Command]) { /// Fills in `qualified_name` fields by appending command name to the parent command name fn set_subcommand_qualified_names(parents: &str, commands: &mut [crate::Command]) { for cmd in commands { - cmd.qualified_name = format!("{} {}", parents, cmd.name); + cmd.qualified_name = Cow::Owned(format!("{} {}", parents, cmd.name)); set_subcommand_qualified_names(&cmd.qualified_name, &mut cmd.subcommands); } } diff --git a/src/lib.rs b/src/lib.rs index ef3c164d6fa9..032a9ef40ca2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -185,7 +185,6 @@ type Context<'a> = poise::Context<'a, Data, Error>; hide_in_help, required_permissions = "SEND_MESSAGES", aliases("bigounce", "abomination"), - help_text_fn = "my_huge_ass_command_help", check = "check", on_error = "error_handler", )] @@ -201,12 +200,6 @@ async fn my_huge_ass_command( Ok(()) } -fn my_huge_ass_command_help() -> String { - String::from("\ -Example usage: -~my_huge_ass_command 127.0.0.1 @kangalio `i = i + 1` my_flag rest of the message") -} - async fn check(ctx: Context<'_>) -> Result { // We discriminate against users starting with an X Ok(!ctx.author().name.starts_with('X')) diff --git a/src/modal.rs b/src/modal.rs index 0ad4c78ac4a5..d1edeb69f8e2 100644 --- a/src/modal.rs +++ b/src/modal.rs @@ -95,7 +95,7 @@ pub async fn execute_modal( ) -> Result, serenity::Error> { let interaction = ctx.interaction; let response = execute_modal_generic( - ctx.serenity_context, + ctx.serenity_context(), |resp| interaction.create_response(ctx, resp), interaction.id.to_string(), defaults, @@ -120,14 +120,14 @@ pub async fn execute_modal( /// 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, + ctx: &serenity::Context, interaction: serenity::ComponentInteraction, defaults: Option, timeout: Option, ) -> Result, serenity::Error> { execute_modal_generic( - ctx.as_ref(), - |resp| interaction.create_response(ctx.as_ref(), resp), + ctx, + |resp| interaction.create_response(ctx, resp), interaction.id.to_string(), defaults, timeout, diff --git a/src/reply/builder.rs b/src/reply/builder.rs index 1fdf6520bb1a..9692c757f1ee 100644 --- a/src/reply/builder.rs +++ b/src/reply/builder.rs @@ -4,28 +4,24 @@ use crate::serenity_prelude as serenity; /// Message builder that abstracts over prefix and application command responses #[derive(Default, Clone)] +#[allow(clippy::missing_docs_in_private_items)] // docs on setters pub struct CreateReply { - /// Message content. - pub content: Option, - /// Embeds, if present. - pub embeds: Vec, - /// Message attachments. - pub attachments: Vec, - /// Whether the message is ephemeral (only has an effect in application commands) - pub ephemeral: Option, - /// Message components, that is, buttons and select menus. - pub components: Option>, - /// The allowed mentions for the message. - pub allowed_mentions: Option, - /// Message poll, if present. - pub poll: Option>, - /// Whether this message is an inline reply. - pub reply: bool, - #[doc(hidden)] - pub __non_exhaustive: (), + content: Option, + embeds: Vec, + attachments: Vec, + pub(crate) ephemeral: Option, + components: Option>, + pub(crate) allowed_mentions: Option, + poll: Option>, + reply: bool, } impl CreateReply { + /// Creates a blank CreateReply. Equivalent to [`Self::default`]. + pub fn new() -> Self { + Self::default() + } + /// Set the content of the message. pub fn content(mut self, content: impl Into) -> Self { self.content = Some(content.into()); @@ -109,7 +105,6 @@ impl CreateReply { allowed_mentions, poll, reply: _, // can't reply to a message in interactions - __non_exhaustive: (), } = self; if let Some(content) = content { @@ -145,7 +140,6 @@ impl CreateReply { allowed_mentions, poll, reply: _, - __non_exhaustive: (), } = self; if let Some(content) = content { @@ -183,7 +177,6 @@ impl CreateReply { // cannot edit polls. poll: _, reply: _, - __non_exhaustive: (), } = self; if let Some(content) = content { @@ -214,7 +207,6 @@ impl CreateReply { // cannot edit polls. poll: _, reply: _, // can't edit reference message afterwards - __non_exhaustive: (), } = self; let mut attachments_builder = serenity::EditAttachments::new(); @@ -249,7 +241,6 @@ impl CreateReply { allowed_mentions, poll, reply, - __non_exhaustive: (), } = self; let mut builder = serenity::CreateMessage::new(); diff --git a/src/reply/send_reply.rs b/src/reply/send_reply.rs index 5308e825da69..b10b1e8a7716 100644 --- a/src/reply/send_reply.rs +++ b/src/reply/send_reply.rs @@ -71,7 +71,7 @@ pub async fn send_application_reply( .to_slash_followup_response(serenity::CreateInteractionResponseFollowup::new()); ctx.interaction - .create_followup(ctx.serenity_context, builder) + .create_followup(ctx.serenity_context(), builder) .await? })) } else { @@ -80,7 +80,7 @@ pub async fn send_application_reply( ctx.interaction .create_response( - ctx.serenity_context, + ctx.serenity_context(), serenity::CreateInteractionResponse::Message(builder), ) .await?; @@ -91,7 +91,7 @@ pub async fn send_application_reply( }; Ok(super::ReplyHandle(super::ReplyHandleInner::Application { - http: &ctx.serenity_context.http, + http: &ctx.serenity_context().http, interaction: ctx.interaction, followup, })) @@ -124,7 +124,7 @@ pub async fn send_prefix_reply( Ok(Box::new(if let Some(mut response) = existing_response { response - .edit(ctx.serenity_context, { + .edit(ctx.serenity_context(), { // Reset the message. We don't want leftovers of the previous message (e.g. user // sends a message with `.content("abc")` in a track_edits command, and the edited // message happens to contain embeds, we don't want to keep those embeds) @@ -150,7 +150,7 @@ pub async fn send_prefix_reply( let new_response = ctx .msg .channel_id - .send_message(ctx.serenity_context, builder.to_prefix(ctx.msg.into())) + .send_message(ctx.serenity_context(), builder.to_prefix(ctx.msg.into())) .await?; // We don't check ctx.command.reuse_response because we need to store bot responses for // track_deletion too diff --git a/src/slash_argument/into_stream.rs b/src/slash_argument/into_stream.rs deleted file mode 100644 index 025207714055..000000000000 --- a/src/slash_argument/into_stream.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Small hacky macro to convert any value into a Stream, where the value can be an `IntoIterator` -//! or a Stream. Used for the return value of autocomplete callbacks - -#[doc(hidden)] -pub struct IntoStreamWrap<'a, T>(pub &'a T); - -#[doc(hidden)] -pub trait IntoStream { - type Output; - // Have to return a callback instead of simply taking a parameter because we're moving T in, - // but self still points into it (`cannot move out of _ because it is borrowed`) - fn converter(self) -> fn(T) -> Self::Output; -} - -impl IntoStream for &IntoStreamWrap<'_, T> { - type Output = futures_util::stream::Iter; - fn converter(self) -> fn(T) -> Self::Output { - |iter| futures_util::stream::iter(iter) - } -} - -impl IntoStream for &&IntoStreamWrap<'_, T> { - type Output = T; - fn converter(self) -> fn(T) -> Self::Output { - |stream| stream - } -} - -/// Takes an expression that is either an IntoIterator or a Stream, and converts it to a Stream -#[doc(hidden)] -#[macro_export] -macro_rules! into_stream { - ($e:expr) => { - match $e { - value => { - use $crate::IntoStream as _; - (&&$crate::IntoStreamWrap(&value)).converter()(value) - } - } - }; -} diff --git a/src/slash_argument/mod.rs b/src/slash_argument/mod.rs index 8911883cd2c1..7b4c58ffd3d4 100644 --- a/src/slash_argument/mod.rs +++ b/src/slash_argument/mod.rs @@ -8,6 +8,3 @@ pub use slash_trait::*; mod context_menu; pub use context_menu::*; - -mod into_stream; -pub use into_stream::*; diff --git a/src/slash_argument/slash_trait.rs b/src/slash_argument/slash_trait.rs index df52687a0d32..fda0001552b6 100644 --- a/src/slash_argument/slash_trait.rs +++ b/src/slash_argument/slash_trait.rs @@ -6,7 +6,7 @@ use std::marker::PhantomData; #[allow(unused_imports)] // import is required if serenity simdjson feature is enabled use crate::serenity::json::*; -use crate::serenity_prelude as serenity; +use crate::{serenity_prelude as serenity, CowVec}; /// Implement this trait on types that you want to use as a slash command parameter. #[async_trait::async_trait] @@ -32,8 +32,8 @@ pub trait SlashArgument: Sized { /// If this is a choice parameter, returns the choices /// /// Don't call this method directly! Use [`crate::slash_argument_choices!`] - fn choices() -> Vec { - Vec::new() + fn choices() -> CowVec { + CowVec::default() } } @@ -53,8 +53,8 @@ pub trait SlashArgumentHack: Sized { fn create(self, builder: serenity::CreateCommandOption) -> serenity::CreateCommandOption; - fn choices(self) -> Vec { - Vec::new() + fn choices(self) -> CowVec { + CowVec::default() } } @@ -177,7 +177,7 @@ impl SlashArgumentHack for &PhantomData { ::create(builder) } - fn choices(self) -> Vec { + fn choices(self) -> CowVec { ::choices() } } diff --git a/src/structs/command.rs b/src/structs/command.rs index b9d9829f174e..27cfb452e492 100644 --- a/src/structs/command.rs +++ b/src/structs/command.rs @@ -2,6 +2,8 @@ use crate::{serenity_prelude as serenity, BoxFuture}; +use super::{CowStr, CowVec}; + /// Type returned from `#[poise::command]` annotated functions, which contains all of the generated /// prefix and application commands #[derive(derivative::Derivative)] @@ -33,31 +35,31 @@ pub struct Command { /// 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, + pub name: CowStr, /// Localized names with locale string as the key (slash-only) - pub name_localizations: std::collections::HashMap, + pub name_localizations: CowVec<(CowStr, CowStr)>, /// Full name including parent command names. /// /// Initially set to just [`Self::name`] and properly populated when the framework is started. - pub qualified_name: String, + pub qualified_name: CowStr, /// A string to identify this particular command within a list of commands. /// /// Can be configured via the [`crate::command`] macro (though it's probably not needed for most /// bots). If not explicitly configured, it falls back to the command function name. - pub identifying_name: String, + pub identifying_name: CowStr, /// The name of the `#[poise::command]`-annotated function - pub source_code_name: String, + pub source_code_name: CowStr, /// Identifier for the category that this command will be displayed in for help commands. - pub category: Option, + pub category: Option, /// Whether to hide this command in help menus. pub hide_in_help: bool, /// Short description of the command. Displayed inline in help menus and similar. - pub description: Option, + pub description: Option, /// Localized descriptions with locale string as the key (slash-only) - pub description_localizations: std::collections::HashMap, + pub description_localizations: CowVec<(CowStr, CowStr)>, /// Multiline description with detailed usage instructions. Displayed in the command specific /// help: `~help command_name` - pub help_text: Option, + pub help_text: Option, /// if `true`, disables automatic cooldown handling before this commands invocation. /// /// Will override [`crate::FrameworkOptions::manual_cooldowns`] allowing manual cooldowns @@ -114,7 +116,7 @@ pub struct Command { // ============= Prefix-specific data /// Alternative triggers for the command (prefix-only) - pub aliases: Vec, + pub aliases: CowVec, /// Whether to rerun the command if an existing invocation message is edited (prefix-only) pub invoke_on_edit: bool, /// Whether to delete the bot response if an existing invocation message is deleted (prefix-only) @@ -124,7 +126,7 @@ pub struct Command { // ============= Application-specific data /// Context menu specific name for this command, displayed in Discord's context menu - pub context_menu_name: Option, + pub context_menu_name: Option, /// Whether responses to this command should be ephemeral by default (application-only) pub ephemeral: bool, /// List of installation contexts for this command (application-only) @@ -159,11 +161,11 @@ impl Command { let description = self.description.as_deref().unwrap_or("A slash command"); let mut builder = serenity::CreateCommandOption::new(kind, self.name.clone(), description); - for (locale, name) in &self.name_localizations { - builder = builder.name_localized(locale, name); + for (locale, name) in self.name_localizations.iter() { + builder = builder.name_localized(locale.as_ref(), name.as_ref()); } - for (locale, description) in &self.description_localizations { - builder = builder.description_localized(locale, description); + for (locale, description) in self.description_localizations.iter() { + builder = builder.description_localized(locale.as_ref(), description.as_ref()); } if self.subcommands.is_empty() { @@ -191,11 +193,11 @@ impl Command { let mut builder = serenity::CreateCommand::new(self.name.clone()) .description(self.description.as_deref().unwrap_or("A slash command")); - for (locale, name) in &self.name_localizations { - builder = builder.name_localized(locale, name); + for (locale, name) in self.name_localizations.iter() { + builder = builder.name_localized(locale.as_ref(), name.as_ref()); } - for (locale, description) in &self.description_localizations { - builder = builder.description_localized(locale, description); + for (locale, description) in self.description_localizations.iter() { + builder = builder.description_localized(locale.as_ref(), description.as_ref()); } // This is_empty check is needed because Discord special cases empty diff --git a/src/structs/context.rs b/src/structs/context.rs index 4c9af0bd743d..561e1bb4e830 100644 --- a/src/structs/context.rs +++ b/src/structs/context.rs @@ -116,7 +116,7 @@ context_methods! { Self::Prefix(ctx) => Some( ctx.msg .channel_id - .start_typing(&ctx.serenity_context.http), + .start_typing(&ctx.serenity_context().http), ), }) } @@ -163,10 +163,7 @@ context_methods! { /// Return the stored [`serenity::Context`] within the underlying context type. (serenity_context self) (pub fn serenity_context(self) -> &'a serenity::Context) { - match self { - Self::Application(ctx) => ctx.serenity_context, - Self::Prefix(ctx) => ctx.serenity_context, - } + self.framework().serenity_context } /// Create a [`crate::CooldownContext`] based off the underlying context type. @@ -199,10 +196,7 @@ context_methods! { /// Return a reference to your custom user data (data self) (pub fn data(self) -> &'a U) { - match self { - Self::Application(ctx) => ctx.data, - Self::Prefix(ctx) => ctx.data, - } + self.framework().user_data } /// Return the channel ID of this context @@ -648,13 +642,8 @@ pub struct PartialContext<'a, U, E> { pub channel_id: serenity::ChannelId, /// ID of the invocation author pub author: &'a serenity::User, - /// Serenity's context, like HTTP or cache - pub serenity_context: &'a serenity::Context, /// Useful if you need the list of commands, for example for a custom help command pub framework: crate::FrameworkContext<'a, U, E>, - /// Your custom user data - // TODO: redundant with framework - pub data: &'a U, #[doc(hidden)] pub __non_exhaustive: (), } @@ -672,9 +661,7 @@ impl<'a, U, E> From> for PartialContext<'a, U, E> { guild_id: ctx.guild_id(), channel_id: ctx.channel_id(), author: ctx.author(), - serenity_context: ctx.serenity_context(), framework: ctx.framework(), - data: ctx.data(), __non_exhaustive: (), } } diff --git a/src/structs/framework_error.rs b/src/structs/framework_error.rs index 9141d7ce4ce5..d8524143b40b 100644 --- a/src/structs/framework_error.rs +++ b/src/structs/framework_error.rs @@ -28,8 +28,6 @@ pub enum FrameworkError<'a, U, E> { EventHandler { /// Error which was thrown in the event handler code error: E, - /// The serenity context passed to the event handler - ctx: &'a serenity::Context, /// Which event was being processed when the error occurred event: &'a serenity::FullEvent, /// The Framework passed to the event @@ -116,6 +114,13 @@ pub enum FrameworkError<'a, U, E> { /// General context ctx: crate::Context<'a, U, E>, }, + /// The command was invoked, but an error occurred while fetching the necessary information to + /// verify permissions. + #[non_exhaustive] + PermissionFetchFailed { + /// General context + ctx: crate::Context<'a, U, E>, + }, /// A non-owner tried to invoke an owners-only command #[non_exhaustive] NotAnOwner { @@ -164,9 +169,6 @@ pub enum FrameworkError<'a, U, E> { /// A message had the correct prefix but the following string was not a recognized command #[non_exhaustive] UnknownCommand { - /// Serenity's Context - #[derivative(Debug = "ignore")] - ctx: &'a serenity::Context, /// The message in question msg: &'a serenity::Message, /// The prefix that was recognized @@ -187,9 +189,6 @@ pub enum FrameworkError<'a, U, E> { /// The command name from the interaction is unrecognized #[non_exhaustive] UnknownInteraction { - #[derivative(Debug = "ignore")] - /// Serenity's Context - ctx: &'a serenity::Context, /// Framework context #[derivative(Debug = "ignore")] framework: crate::FrameworkContext<'a, U, E>, @@ -201,9 +200,6 @@ pub enum FrameworkError<'a, U, E> { NonCommandMessage { /// The error thrown by user code error: E, - #[derivative(Debug = "ignore")] - /// Serenity's Context - ctx: &'a serenity::Context, /// Framework context #[derivative(Debug = "ignore")] framework: crate::FrameworkContext<'a, U, E>, @@ -220,24 +216,25 @@ impl<'a, U, E> FrameworkError<'a, U, E> { pub fn serenity_context(&self) -> &'a serenity::Context { match *self { Self::Setup { ctx, .. } => ctx, - Self::EventHandler { ctx, .. } => ctx, + Self::EventHandler { framework, .. } => framework.serenity_context, 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, + Self::CommandStructureMismatch { ctx, .. } => ctx.framework.serenity_context, Self::CooldownHit { ctx, .. } => ctx.serenity_context(), Self::MissingBotPermissions { ctx, .. } => ctx.serenity_context(), Self::MissingUserPermissions { ctx, .. } => ctx.serenity_context(), + Self::PermissionFetchFailed { ctx } => ctx.serenity_context(), Self::NotAnOwner { ctx, .. } => ctx.serenity_context(), Self::GuildOnly { ctx, .. } => ctx.serenity_context(), Self::DmOnly { ctx, .. } => ctx.serenity_context(), Self::NsfwOnly { ctx, .. } => ctx.serenity_context(), Self::CommandCheckFailed { ctx, .. } => ctx.serenity_context(), - Self::DynamicPrefix { ctx, .. } => ctx.serenity_context, - Self::UnknownCommand { ctx, .. } => ctx, - Self::UnknownInteraction { ctx, .. } => ctx, - Self::NonCommandMessage { ctx, .. } => ctx, + Self::DynamicPrefix { ctx, .. } => ctx.framework.serenity_context, + Self::UnknownCommand { framework, .. } => framework.serenity_context, + Self::UnknownInteraction { framework, .. } => framework.serenity_context, + Self::NonCommandMessage { framework, .. } => framework.serenity_context, Self::__NonExhaustive(unreachable) => match unreachable {}, } } @@ -253,6 +250,7 @@ impl<'a, U, E> FrameworkError<'a, U, E> { Self::CooldownHit { ctx, .. } => ctx, Self::MissingBotPermissions { ctx, .. } => ctx, Self::MissingUserPermissions { ctx, .. } => ctx, + Self::PermissionFetchFailed { ctx } => ctx, Self::NotAnOwner { ctx, .. } => ctx, Self::GuildOnly { ctx, .. } => ctx, Self::DmOnly { ctx, .. } => ctx, @@ -378,6 +376,11 @@ impl std::fmt::Display for FrameworkError<'_, U, E> { missing_permissions, full_command_name!(ctx), ), + Self::PermissionFetchFailed { ctx } => write!( + f, + "An error occurred when trying to fetch permissions for `{}`", + full_command_name!(ctx) + ), Self::NotAnOwner { ctx } => write!( f, "owner-only command `{}` cannot be run by non-owners", @@ -447,6 +450,7 @@ impl<'a, U: std::fmt::Debug, E: std::error::Error + 'static> std::error::Error Self::CooldownHit { .. } => None, Self::MissingBotPermissions { .. } => None, Self::MissingUserPermissions { .. } => None, + Self::PermissionFetchFailed { .. } => None, Self::NotAnOwner { .. } => None, Self::GuildOnly { .. } => None, Self::DmOnly { .. } => None, diff --git a/src/structs/framework_options.rs b/src/structs/framework_options.rs index 1f7403e1c55a..418bddcf9c8a 100644 --- a/src/structs/framework_options.rs +++ b/src/structs/framework_options.rs @@ -49,11 +49,8 @@ pub struct FrameworkOptions { /// deletions or guild updates. #[derivative(Debug = "ignore")] pub event_handler: for<'a> fn( - &'a serenity::Context, - &'a serenity::FullEvent, crate::FrameworkContext<'a, U, E>, - // TODO: redundant with framework - &'a U, + &'a serenity::FullEvent, ) -> BoxFuture<'a, Result<(), E>>, /// Renamed to [`Self::event_handler`]! #[deprecated = "renamed to event_handler"] @@ -108,7 +105,7 @@ where } }) }, - event_handler: |_, _, _, _| Box::pin(async { Ok(()) }), + event_handler: |_, _| Box::pin(async { Ok(()) }), listener: (), pre_command: |_| Box::pin(async {}), post_command: |_| Box::pin(async {}), diff --git a/src/structs/mod.rs b/src/structs/mod.rs index c0b36a21ec62..9f51390324a0 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -1,5 +1,7 @@ //! Plain data structs that define the framework configuration. +use std::borrow::Cow; + mod context; pub use context::*; @@ -17,3 +19,9 @@ pub use slash::*; mod framework_error; pub use framework_error::*; + +/// A type alias for `&'static str` or `String` +pub(crate) type CowStr = Cow<'static, str>; + +/// A type alias for `&'static [T]` or `Vec` +pub(crate) type CowVec = Cow<'static, [T]>; diff --git a/src/structs/prefix.rs b/src/structs/prefix.rs index f7615b885e1f..8a754b6250fe 100644 --- a/src/structs/prefix.rs +++ b/src/structs/prefix.rs @@ -1,5 +1,7 @@ //! Holds prefix-command definition structs. +use std::borrow::Cow; + use crate::{serenity_prelude as serenity, BoxFuture}; /// The event that triggered a prefix command execution @@ -22,9 +24,6 @@ pub enum MessageDispatchTrigger { #[derive(derivative::Derivative)] #[derivative(Debug(bound = ""))] pub struct PrefixContext<'a, U, E> { - /// Serenity's context, like HTTP or cache - #[derivative(Debug = "ignore")] - pub serenity_context: &'a serenity::Context, /// The invoking user message pub msg: &'a serenity::Message, /// Prefix used by the user to invoke this command @@ -42,10 +41,6 @@ pub struct PrefixContext<'a, U, E> { pub parent_commands: &'a [&'a crate::Command], /// The command object which is the current command pub command: &'a crate::Command, - /// Your custom user data - // TODO: redundant with framework - #[derivative(Debug = "ignore")] - pub data: &'a U, /// Custom user data carried across a single command invocation pub invocation_data: &'a tokio::sync::Mutex>, /// How this command invocation was triggered @@ -89,7 +84,7 @@ pub enum Prefix { pub struct PrefixFrameworkOptions { /// The main bot prefix. Can be set to None if the bot supports only /// [dynamic prefixes](Self::dynamic_prefix). - pub prefix: Option, + pub prefix: Option>, /// List of additional bot prefixes // TODO: maybe it would be nicer to have separate fields for literal and regex prefixes // That way, you don't need to wrap every single literal prefix in a long path which looks ugly @@ -100,8 +95,9 @@ pub struct PrefixFrameworkOptions { /// /// For more advanced dynamic prefixes, see [`Self::stripped_dynamic_prefix`] #[derivative(Debug = "ignore")] - pub dynamic_prefix: - Option) -> BoxFuture<'_, Result, E>>>, + pub dynamic_prefix: Option< + fn(crate::PartialContext<'_, U, E>) -> BoxFuture<'_, Result>, E>>, + >, /// Callback invoked on every message to strip the prefix off an incoming message. /// /// Override this field for advanced dynamic prefixes which change depending on guild or user. @@ -157,7 +153,6 @@ pub struct PrefixFrameworkOptions { pub non_command_message: Option< for<'a> fn( &'a crate::FrameworkContext<'a, U, E>, - &'a serenity::Context, &'a serenity::Message, ) -> crate::BoxFuture<'a, Result<(), E>>, >, diff --git a/src/structs/slash.rs b/src/structs/slash.rs index 9419bc923437..ca9082a46ea5 100644 --- a/src/structs/slash.rs +++ b/src/structs/slash.rs @@ -2,6 +2,8 @@ use crate::{serenity_prelude as serenity, BoxFuture}; +use super::{CowStr, CowVec}; + /// Specifies if the current invokation is from a Command or Autocomplete. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum CommandInteractionType { @@ -15,9 +17,6 @@ pub enum CommandInteractionType { #[derive(derivative::Derivative)] #[derivative(Debug(bound = ""))] pub struct ApplicationContext<'a, U, E> { - /// Serenity's context, like HTTP or cache - #[derivative(Debug = "ignore")] - pub serenity_context: &'a serenity::Context, /// The interaction which triggered this command execution. pub interaction: &'a serenity::CommandInteraction, /// The type of the interaction which triggered this command execution. @@ -41,10 +40,6 @@ pub struct ApplicationContext<'a, U, E> { pub parent_commands: &'a [&'a crate::Command], /// The command object which is the current command pub command: &'a crate::Command, - /// Your custom user data - // TODO: redundant with framework - #[derivative(Debug = "ignore")] - pub data: &'a U, /// Custom user data carried across a single command invocation pub invocation_data: &'a tokio::sync::Mutex>, // #[non_exhaustive] forbids struct update syntax for ?? reason @@ -74,7 +69,7 @@ impl ApplicationContext<'_, U, E> { ); self.interaction - .create_response(self.serenity_context, response) + .create_response(self.framework.serenity_context, response) .await?; self.has_sent_initial_response @@ -114,13 +109,62 @@ impl Clone for ContextMenuCommandAction { } } +/// An enum to hold the different limits a CommandParameter may have. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum ValueLimits { + /// Used if the CommandParameter is a string. + Length { + /// See [`serenity::CreateCommandOption::min_length`] + min: Option, + /// See [`serenity::CreateCommandOption::max_length`] + max: Option, + }, + /// Used if the CommandParameter is an integer or number. + Value { + /// See [`serenity::CreateCommandOption::min_number_value`] + min: Option, + /// See [`serenity::CreateCommandOption::max_number_value`] + max: Option, + }, +} + +impl ValueLimits { + /// Applies the limits to a [`serenity::CreateCommandOption`]. + pub fn apply_to_slash_command_option( + self, + mut builder: serenity::CreateCommandOption, + ) -> serenity::CreateCommandOption { + match self { + ValueLimits::Length { min, max } => { + if let Some(min) = min { + builder = builder.min_length(min); + } + if let Some(max) = max { + builder = builder.max_length(max); + } + } + ValueLimits::Value { min, max } => { + if let Some(min) = min { + builder = builder.min_number_value(min); + } + if let Some(max) = max { + builder = builder.max_number_value(max); + } + } + } + + builder + } +} + /// A single drop-down choice in a slash command choice parameter #[derive(Debug, Clone)] pub struct CommandParameterChoice { /// Label of this choice - pub name: String, + pub name: CowStr, /// Localized labels with locale string as the key (slash-only) - pub localizations: std::collections::HashMap, + pub localizations: CowVec<(CowStr, CowStr)>, #[doc(hidden)] pub __non_exhaustive: (), } @@ -130,28 +174,30 @@ pub struct CommandParameterChoice { #[derivative(Debug(bound = ""))] pub struct CommandParameter { /// Name of this command parameter - pub name: String, + pub name: CowStr, /// Localized names with locale string as the key (slash-only) - pub name_localizations: std::collections::HashMap, + pub name_localizations: CowVec<(CowStr, CowStr)>, /// Description of the command. Required for slash commands - pub description: Option, + pub description: Option, /// Localized descriptions with locale string as the key (slash-only) - pub description_localizations: std::collections::HashMap, + pub description_localizations: CowVec<(CowStr, CowStr)>, /// `true` is this parameter is required, `false` if it's optional or variadic pub required: bool, /// If this parameter is a channel, users can only enter these channel types in a slash command /// /// Prefix commands are currently unaffected by this - pub channel_types: Option>, + pub channel_types: Option>, /// If this parameter is a choice parameter, this is the fixed list of options - pub choices: Vec, + pub choices: CowVec, + /// For String or Number argument types, this contains the limits. + pub value_limits: Option, /// Closure that sets this parameter's type and min/max value in the given builder /// /// For example a u32 [`CommandParameter`] would store this as the [`Self::type_setter`]: /// ```rust /// # use poise::serenity_prelude as serenity; /// # let _: fn(serenity::CreateCommandOption) -> serenity::CreateCommandOption = - /// |b| b.kind(serenity::CommandOptionType::Integer).min_int_value(0).max_int_value(u64::MAX) + /// |b| b.kind(serenity::CommandOptionType::Integer) /// # ; /// ``` #[derivative(Debug = "ignore")] @@ -164,10 +210,7 @@ pub struct CommandParameter { for<'a> fn( crate::ApplicationContext<'a, U, E>, &'a str, - ) -> BoxFuture< - 'a, - Result, - >, + ) -> BoxFuture<'a, serenity::CreateAutocompleteResponse>, >, #[doc(hidden)] pub __non_exhaustive: (), @@ -192,18 +235,27 @@ impl CommandParameter { .required(self.required) .set_autocomplete(self.autocomplete_callback.is_some()); - for (locale, name) in &self.name_localizations { - builder = builder.name_localized(locale, name); + for (locale, name) in self.name_localizations.iter() { + builder = builder.name_localized(locale.as_ref(), name.as_ref()); } - for (locale, description) in &self.description_localizations { - builder = builder.description_localized(locale, description); + for (locale, description) in self.description_localizations.iter() { + builder = builder.description_localized(locale.as_ref(), description.as_ref()); } - if let Some(channel_types) = self.channel_types.clone() { - builder = builder.channel_types(channel_types); + if let Some(channel_types) = self.channel_types.as_deref() { + builder = builder.channel_types(channel_types.to_owned()); } for (i, choice) in self.choices.iter().enumerate() { - builder = - builder.add_int_choice_localized(&choice.name, i as _, choice.localizations.iter()); + builder = builder.add_int_choice_localized( + choice.name.as_ref(), + i as _, + choice + .localizations + .iter() + .map(|(name, description)| (name.as_ref(), description.as_ref())), + ); + } + if let Some(value_limits) = self.value_limits { + builder = value_limits.apply_to_slash_command_option(builder) } Some((self.type_setter?)(builder))