From 2fcf9546e77408c2bea3c1a7c365369d5c93513d Mon Sep 17 00:00:00 2001 From: scottbot95 Date: Tue, 19 Sep 2023 04:26:29 -0700 Subject: [PATCH] Merge `next` into `serenity-next` (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move check inherit testing code to separate file * Quickstart: auto-register commands on startup * user_data_setup->setup But keep a deprecated function alias so this is not breaking * Rearrange quickstart type definitions To save one line and to make the ordering more logical (Data and Error always appear in this order in poise, and Context should follow after because it refers to the previous two) * Add register_in_guild and register_globally convenience * Rename listener to event_handler * Fix fmt and docs * Change autocomplete callback value conversion from json::Value::from() to json::to_value() [json!() macro does this]. (#128) * serenity_context and poise::Context traits * Allow supplying timeout to modal execute * Fix example * Fix docs * Bump to 0.5.0 * [Feature] Allow to bypass command checks for owners (#130) * introduce skip_checks_for_owner feature * fix skip_checks_for_owners * fix missing owner check for `skip_checks_for_owners` * Separate cooldowns and min max testing It was stuffed into the add command before, now it's separate commands `cooldowns` and `minmax` * Don't panic on modal timeout * Fallback to HTTP in user_permissions even with cache enabled * Update rustdoc-scrape-examples invocation * Bump to 0.5.1 * Update Cargo.lock To get rid of "cpufeatures 0.2.2 is yanked" message * Impl SlashArgument instead of SlashArgumentHack For primitive types and model types, the slash argumen trait impl is using SlashArgument instead of SlashArgumentHack now, which means those implementations are visible in the docs. Which is very much nicer than before. Also, this commit makes SlashArgument::choices a default method. I decided it's annoying to have to unconditionally implement it even though it's `Vec::new()` in most cases. I originally didn't make it a default method just for "safety" so you don't forget to implement it _if_ you need it but I think that's a bad argument * Fix sneaky bug Welp and I was wondering why editing non-track_edits command `~say` re-runs the command (but not reuses response, or tracks deletions (coming in next commit))... It's because dispatch_message gets given a MessageEditFromInvalid... But why? Yeah because I mixed up the two match cases * Add track_deletion * Make builtins::servers aware of teams. (#133) * Add all Context's methods to Prefix/ApplicationContext Theoretically a small breaking change (some Context methods changed from &self to self. Some lifetimes were also changed from the &self lifetime to 'a, but this is not breaking because the lifetime has been strictly expanded. Anyways I'm not making a breaking release for this because it will realistically affect nobody and it causes more total pain making this a breaking release) * Increase MSRV to 1.60.0 due to time crate And set rust-version Cargo.toml field for the first time (it was added in 1.56) * Update CI badge in README https://github.com/badges/shields/issues/8671 * Bump to 0.5.2 * Add missing `Detailed changelog`'s in CHANGELOG * Add paginate * Always print Command errors to console also The result of a lengthy debugging ssession with Logan King. The problem: if a slash command initial response has an error (e.g. empty content), the interaction completely expires, you can't use it anymore. Any further attempt at sending a response is `Unknown Interaction` or `Unknown Webhook`. So the default error handler for command errors, which just sends the error message to Discord, ran into this, and was unable to send the error message to Discord. And the FrameworkOptions::on_error default error handler emits a log::error in that case, but obv the user doesn't see that when not having logging configured. So Logan King was rightly really confused why their bot was just endlessly showing "... Sending command ..." - that's apparently Discord's helpful way of saying "the bot did respond, but with an error, so we locked this interaction ^.^ and instead of 'this bot responded incorrectly' or something we'll show an endless loading message! this will definitely not confuse anyone ever! ^.^". ANYHOW, this commit unconditionally prints Command errors via eprintln!() as well in the default error handler, so it can't be missed * Feature guard pagination example (#138) * Print login message in framework_usage * Add missing commas in macros for localizations (#143) * Add missing commas in macros for localizations * Add testing code --------- Co-authored-by: kangalioo * Document default_member_permissions Fixes #145 * Add EventWrapper example Fixes #146 * Add missing events to EventWrapper (#144) * Add unit test example Fixes #142 * Update links in README.md (kangalioo -> serenity-rs) * Add panic handler (#140) * Restructure dispatch::slash functions like prefix Forgot some stuff from restructguring * Catch panics during command execution - Adds FrameworkError::CommandPanic - The default on_error impl spawns a nice error embed - Wraps all instances of run_invocation/run_interaction/run_autocomplete in catch_unwind - Adds a `div` command to the testing crate to test the panic catching (by triggering a div by zero) * Replace let-else with match to please MSRV * Gate catch_unwind call behind feature flag --------- Co-authored-by: kangalioo * Update kangalioo -> serenity-rs and fix typos Changes extracted from the pre-force-push #140 (https://github.com/serenity-rs/poise/compare/6c082b5bddea7374a3c1b2d72be0a86116368afb...1bac1988a6697187210f880e3bd40b82e1fea056) * Split develop branch into current and next * Bump to 0.5.3 * Fix FrameworkContext::user_data being async (#137) * Fix FrameworkContext::user_data being async * fmt * Make FrameworkContext::user_data async again I realized the function was supposed to be async to stay API-compatible with the Framework methods, that's how it was created in c10ec4d9b584564558841dd304bb6294dd2147c1. This commit basically reverts #137, except for the lint changes * Optionally display subcommands in help menu Only one recursion level deep so far because infinite recursion would have been a pain and I don't even know if that's what users want so it's single recursion level for now and if someone complains I'll go through the async recursion trouble * Amend to previous commit: clippy allow * Remove redundant import in testing crate * Change CommandPanic payload type to Option Previously, it was Box - making the entire type not Sync. This wasn't intentional. This is technically a breaking change, but since CommandPanic was only introduced a few days ago, and was itself a breaking change, this is basically just a quick hotfix reversal of a breaking change. * Bump to 0.5.4 * Support #[min_length] #[max_length] Fixes #154 * Bump to 0.5.5 * Make a bunch of structs non_exhaustive... ...and rename AutocompleteChoice.name to label (I think it fits better) * Forgot enums (while applying non_exhaustive) * Test RoleParseError downcasting * Rework of builtins::help() (#161) * Rework of builtins::help() * Use find_command instead of duplicating code * Fix env variable `set` -> `export` for Unix * Fix unneeded mut * cargo fmt --------- Co-authored-by: kangalioo * Add HelpConfiguration:include_description #161 changed single help command to include description. This commit makes it optional (but keeps the new behavior as default) * Fix cargo test * Change Command field types - category: `Option<&'static str>` -> `Option` - help_text: `Option String>` -> `Option` - aliases: `&'static [&'static str]` -> `Vec` - context_menu_name: `Option<&'static str>` -> `Option` The &'static references meant you couldn't dynamically change those values which is very powerful so better do this breaking change now than potentially later. It's not like it hurts to use String and Vec here due to the allocations - every bot will have a two-digit, max three-digit number of Command instances anyways and they will all be initialized on startup. Also, the fn() in help_text was really ugly and was just a hack for rustbot to share common parts of the help text. But this should be done by just changing the help_text field in main() or whatever. Actually though, I was able to keep the #[command] attribute's help_text_fn field working. But it runs the function at command construction time now instead of on demand when help is called. So, behavior change technically * Deduplicate CreateReply creation code Adds Context::reply_builder * Remove outdated TODO * Remove pub(super) and make private instead In my opinion pub(crate) or pub(any other path) is a code smell. If you're trying to use it, then either: - your module structure is messed up and you should move code that accesses implementation detail of your type into the same module as that type (this was the reason for the pub(super) from this commit. The required restructuring I apparently already did some time ago) - those "implementation" details should actually be public because trying to fully use that type evidently requires accessing those details (that's why for example poise's EditTracker methods are public, or why the CreateReply::reply_builder method I recently added is public) * Remove unused function warning when testing * Okay apparently Discord needs "es-ES" instead of "es" now * Add ChoiceParameter trait instead of direct impls Fixes https://github.com/serenity-rs/poise/issues/126 * fix: typo (#165) Co-authored-by: xtfdfr * Support pattern parameters in #[command] Fixes https://github.com/serenity-rs/poise/issues/158 Wow this more complicated than I expected * Fix with simd-json (amend to 8471b6e358ae5ff0369fc408159f1188352790c1) * Restructure `#[command]` docs into sub headings * Refinements to builtins::help (#167) * Refinements to builtins::help * Make rustfmt happy * Make MSRV happy --------- Co-authored-by: Rogier 'DocWilco' Mulhuijzen * Add Infallible to __NonExhaustive enum variants * Restructure examples framework_usage and testing are gone in favor of basic_structure and framework_features. The examples/README now describes what each example contains. And the root README points to the examples folder [amend commit] fix rustfmt [amend commit] fix some things to make the example actually runnable (lol), fix feature gates, use poise::builtins::register_globally * Add Context::{cache, http} shorthands And remove the internal `.sc()` function in favor of the `.cache()` shorthand or full blown `.serenity_context()` [amend commit] fix feature gate * Use eprintln in default Setup error handler Previously it used log::error which is not visible enough. If someone doesn't have a logger configured, they will miss it and it will cause BIG TIME confusion. Had it happen to someone I helped debug in #poise, and I Just fell into that trap myself and was downloading and using CodeLLDB, looking through tokio internals, looking up async debuggers, placing dbg!()'s deep within serenity's HTTP code; before finding out it was just some setup error * feature: subcommmand_required parameter (#170) * Add Infallible to __NonExhaustive enum variants * add: subcommmand_required * Fix a few things - The code was not properly formatted (`cargo fmt --all`) - There was a redundant is_subcommand value added to a bunch of things (including find_command - this PR was a breaking change and would have belonged to the `next` branch! But since is_subcommand was redundant and I removed it, this PR becomes not-breaking again (which makes sense; this kind of small feature shouldn't need breaking changes)) - The is_subcommand check was in the wrong place. The parse_invocation is really only for parsing, not for any action. If it returns an error, it should be an error in _parsing_, not in execution or usage. And it also belongs after the track_edits related checks, because if a message was edited that poise isn't even allowed to care about, it shouldn't throw an error too (this would have been another problem with the is_subcommand check in parse_invocation) * Improve text --------- Co-authored-by: kangalioo Co-authored-by: xtfdfr * Make FrameworkError and SlashArgError variants non_exhaustive * Added Support for Opening Modals as an MCI Response (#173) * feat: added support for responding to an mci with a modal * feat: added example, simplified modal send as mci interface * fix: fixed example * Some changes ™ - Move component modal example into modal.rs instead of making new file - Make component modal example prefix_command too to catch any oversights that may make component modals rely on a slash command interaction being present (e.g. previously, execute_component_interaction took ApplicationContext and couldn't be invoked in prefix context at all) - Deduplicate code between execute_modal and execute_component_interaction (and rename the latter to be clearer) - Remove Modal::execute_component_interaction function in favor of free-standing execute_modal_on_component_interaction function - I want to avoid an explosion of utility trait functions for any combination of defaults yes/no, component yes/no... That said, I realize we're already halfway in this mess with execute and execute_with_defaults both existing so I'd also be open to go all the way and add execute_component_interaction and execute_component_interaction_with_defaults - Add `impl AsRef for poise::Context` to make free-standing execute_modal_on_component_interaction function work well; and because it makes sense in general to have --------- Co-authored-by: kangalioo * Add CooldownContext struct to make Cooldowns usable in more situations (#175) * Convert Cooldowns to use HashMap instead of OrderedMap for better scaling (#180) * Fix failing CI build (#181) - Update serenity 0.11.6 -> 0.11.5 - Update proc_macro2 1.0.47 -> 1.0.64 - Disambiguate several serenity re-exports - Temporarily disable serenity/simdjson test since feature is broken upstream (serenity-rs/serenity#2474) * add `reply()` for `poise::context` (#183) * fix the behavior of poise::reply::builder::CreateReply.reply * unnecessary change by mistake * change back to original * add reply() * remove reply_reply() * Adhere to unwritten codebase conventions Made on phone in GitHub's absolutely janky web UI, and untested. Let's see how this goes --------- Co-authored-by: kangalio * Two dependency bumps (#186) * Bump `tokio` to `1.25.1` * Bump `env_logger` to `1.10.0` * Bump `tokio` to `1.25.1` * Allow passing cooldown config on demand That way, you can have different cooldown durations depending on which user / guild / whatever is invoking the command * Remove old `CooldownTracker` methods and migrate internals - Renames `CooldownTracker::new_2` and `CooldownTracker::remaining_cooldown_2` to `CooldownTracker::new` and `CooldownTracker::remaining_cooldown` respectively. - Adds a new `cooldown_config` field to `Command` wrapped in a Mutex to allow updating by the user - Adds a new example using a command check to modify the `cooldown_config` on the `Command` - updates `#[poise::command]` to new `Command` structure * Bump MSRV to 1.63 Needed for dependency updates in #186 that were done to resolve some `cargo audit` vulnerabilities * Remove uneccessary NonZeroU64 usage The new serenity id types impl PartialEq and From so we don't need to bother with hhe NonZeroU64 directly * Fix copy paste typo in pagination button IDs Co-authored-by: jamesbt365 * Fix empty initial message in pagination Co-authored-by: jamesbt365 --------- Co-authored-by: kangalioo Co-authored-by: ChancedRigor Co-authored-by: Peanutbother <6437182+peanutbother@users.noreply.github.com> Co-authored-by: Andre Julius Co-authored-by: Gnome! <45660393+GnomedDev@users.noreply.github.com> Co-authored-by: Maximilian Mader Co-authored-by: Whitney <67877488+whitbur@users.noreply.github.com> Co-authored-by: Horu <73709188+HigherOrderLogic@users.noreply.github.com> Co-authored-by: docwilco <66911096+docwilco@users.noreply.github.com> Co-authored-by: franuś <97941280+xtfdfr@users.noreply.github.com> Co-authored-by: xtfdfr Co-authored-by: Rogier 'DocWilco' Mulhuijzen Co-authored-by: G0ldenSp00n <71467406+G0ldenSp00n@users.noreply.github.com> Co-authored-by: B-2U <82710122+B-2U@users.noreply.github.com> Co-authored-by: Overzealous Lotus <103554783+OverzealousLotus@users.noreply.github.com> Co-authored-by: jamesbt365 --- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 83 +++ Cargo.lock | 94 ++- Cargo.toml | 25 +- README.md | 14 +- examples/README.md | 46 ++ examples/advanced_cooldowns/main.rs | 57 ++ examples/basic_structure/commands.rs | 77 ++ .../main.rs | 85 +-- .../feature_showcase/attachment_parameter.rs | 36 + .../autocomplete.rs | 12 +- examples/feature_showcase/bool_parameter.rs | 19 + examples/feature_showcase/builtins.rs | 18 + .../commands => feature_showcase}/checks.rs | 39 +- examples/feature_showcase/choice_parameter.rs | 22 + .../feature_showcase/code_block_parameter.rs | 12 + examples/feature_showcase/collector.rs | 43 ++ .../context_menu.rs | 0 examples/feature_showcase/inherit_checks.rs | 38 + .../localization.rs | 18 +- examples/feature_showcase/main.rs | 112 +++ examples/feature_showcase/modal.rs | 54 ++ examples/feature_showcase/paginate.rs | 15 + examples/feature_showcase/panic_handler.rs | 10 + .../feature_showcase/parameter_attributes.rs | 100 +++ examples/feature_showcase/raw_identifiers.rs | 8 + .../feature_showcase/response_with_reply.rs | 7 + .../feature_showcase/subcommand_required.rs | 34 + .../subcommands.rs | 0 examples/feature_showcase/track_edits.rs | 56 ++ examples/fluent_localization/main.rs | 2 + examples/fluent_localization/translation.rs | 21 +- examples/framework_usage/commands/general.rs | 346 --------- examples/framework_usage/commands/mod.rs | 6 - examples/help_generation/main.rs | 359 ++++++++++ examples/manual_dispatch/main.rs | 2 +- examples/quickstart/main.rs | 18 +- examples/serenity_example_port/main.rs | 676 ------------------ examples/testing/main.rs | 87 --- macros/Cargo.toml | 6 +- macros/src/choice_parameter.rs | 68 +- macros/src/command/mod.rs | 48 +- macros/src/command/prefix.rs | 40 +- macros/src/command/slash.rs | 73 +- macros/src/lib.rs | 75 +- macros/src/util.rs | 10 +- release-guide.md | 4 +- src/builtins/help.rs | 339 +++++++-- src/builtins/mod.rs | 61 +- src/builtins/paginate.rs | 91 +++ src/builtins/register.rs | 50 +- src/choice_parameter.rs | 85 +++ src/cooldown.rs | 97 ++- src/dispatch/common.rs | 56 +- src/dispatch/mod.rs | 35 +- src/dispatch/prefix.rs | 40 +- src/dispatch/slash.rs | 155 ++-- src/framework/builder.rs | 37 +- src/framework/mod.rs | 12 +- src/lib.rs | 123 +++- src/modal.rs | 149 ++-- src/prefix_argument/argument_trait.rs | 10 +- src/prefix_argument/code_block.rs | 32 +- src/prefix_argument/macros.rs | 37 +- src/prefix_argument/mod.rs | 37 +- src/reply/builder.rs | 7 + src/reply/mod.rs | 14 +- src/reply/send_reply.rs | 43 +- src/slash_argument/autocompletable.rs | 35 +- src/slash_argument/context_menu.rs | 2 +- src/slash_argument/slash_macro.rs | 36 +- src/slash_argument/slash_trait.rs | 35 +- src/structs/command.rs | 24 +- src/structs/context.rs | 399 +++++++---- src/structs/framework_error.rs | 138 +++- src/structs/framework_options.rs | 14 +- src/structs/prefix.rs | 6 +- src/structs/slash.rs | 19 +- src/track_edits.rs | 84 ++- src/util.rs | 2 + 81 files changed, 3291 insertions(+), 1998 deletions(-) create mode 100644 examples/advanced_cooldowns/main.rs create mode 100644 examples/basic_structure/commands.rs rename examples/{framework_usage => basic_structure}/main.rs (57%) create mode 100644 examples/feature_showcase/attachment_parameter.rs rename examples/{framework_usage/commands => feature_showcase}/autocomplete.rs (94%) create mode 100644 examples/feature_showcase/bool_parameter.rs create mode 100644 examples/feature_showcase/builtins.rs rename examples/{framework_usage/commands => feature_showcase}/checks.rs (76%) create mode 100644 examples/feature_showcase/choice_parameter.rs create mode 100644 examples/feature_showcase/code_block_parameter.rs create mode 100644 examples/feature_showcase/collector.rs rename examples/{framework_usage/commands => feature_showcase}/context_menu.rs (100%) create mode 100644 examples/feature_showcase/inherit_checks.rs rename examples/{framework_usage/commands => feature_showcase}/localization.rs (67%) create mode 100644 examples/feature_showcase/main.rs create mode 100644 examples/feature_showcase/modal.rs create mode 100644 examples/feature_showcase/paginate.rs create mode 100644 examples/feature_showcase/panic_handler.rs create mode 100644 examples/feature_showcase/parameter_attributes.rs create mode 100644 examples/feature_showcase/raw_identifiers.rs create mode 100644 examples/feature_showcase/response_with_reply.rs create mode 100644 examples/feature_showcase/subcommand_required.rs rename examples/{framework_usage/commands => feature_showcase}/subcommands.rs (100%) create mode 100644 examples/feature_showcase/track_edits.rs delete mode 100644 examples/framework_usage/commands/general.rs delete mode 100644 examples/framework_usage/commands/mod.rs create mode 100644 examples/help_generation/main.rs delete mode 100644 examples/serenity_example_port/main.rs delete mode 100644 examples/testing/main.rs create mode 100644 src/builtins/paginate.rs create mode 100644 src/choice_parameter.rs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bacade2d5892..fef5a4fa2019 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1 @@ - + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdda3ffb633f..ee99e8c39f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: no features # don't test examples because they need collector feature command: cargo test --no-default-features --features serenity/rustls_backend --lib --tests - + - name: all features + simd_json command: cargo test --all-features --features serenity/simd_json --lib --tests --examples rustflags: -C target-cpu=haswell # needed for simd_json @@ -103,16 +103,16 @@ jobs: RUSTFLAGS: -C target-cpu=haswell # for simd-json RUSTDOCFLAGS: --cfg doc_nightly -D warnings - # If on develop branch (as opposed to PR or whatever), publish docs to github pages + # If on current/next branch (as opposed to PR or whatever), publish docs to github pages - name: Move files - if: github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/current' || github.ref == 'refs/heads/next' shell: bash run: | DIR=${GITHUB_REF#refs/heads/} mkdir -p ./docs/$DIR mv ./target/doc/* ./docs/$DIR/ - name: Deploy docs - if: github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/current' || github.ref == 'refs/heads/next' uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f2d1d800d2..05042ff28a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,86 @@ +# 0.5.5 + +New features: +- Added `#[min_length]` and `#[max_length]` support for slash command string parameters + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.5.4...v0.5.5 + +# 0.5.4 + +API updates: +- The `payload` field of `FrameworkError::CommandPanic` has been changed from `Box` to `Option` + - This is technically a breaking change + - However, the newly introduced `payload` field in 0.5.3 made `FrameworkError` accidentally not Sync anymore + - And `FrameworkError::CommandPanic` has only been introduced a few days ago in 0.5.3 + - Therefore, I think it's ok to release this as a patch release to reverse the accidental breaking change from 0.5.3 + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.5.3...v0.5.4 + +# 0.5.3 + +New features: +- Added `builtins::paginate()` as an example implementation of pagination +- Added missing events in `EventWrapper` (#144) +- Added `FrameworkError::CommandPanic` to allow custom handling of panics (#140) + - `builtins::on_error` responds with an "Internal error" embed when encountering `CommandPanic` + +Behavior changes: +- `builtins::on_error` now prints `FrameworkError::Command` not just in Discord chat, but in console as well + - because responding in Discord sometimes doesn't work, see 0a03fb905ca0bc3b2ee0701fe35d3c89ecf5a654 +- Fixed a compile error when `name_localized` or `description_localized` are used multiple times (#143) + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.5.2...v0.5.3 + +# 0.5.2 + +New features: +- Added `track_deletion` feature to commands +- Added all of `Context`'s methods to `PrefixContext` and `ApplicationContext` + +Behavior changes: +- Editing commands not marked track_edits no longer re-runs the command +- `builtins::servers` now shows hidden statistics for the entire bot team, not just owner + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.5.1...v0.5.2 + +# 0.5.1 + +New features: +- Added `FrameworkOptions::skip_checks_for_owner` + +Behavior changes: +- `execute_modal` doesn't panic anymore when the timeout is reached +- Checking user permissions properly falls back to HTTP when cache is enabled but empty + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.5.0...v0.5.1 + +# 0.5.0 + +New features: +- Added `Context::parent_commands()` +- Added `Context::invocation_string()` +- Added `builtins::register_in_guild()` and `builtins::register_globally()` convenience functions +- The return value of autocomplete callbacks can be any serializable type now +- `Context` can now be passed directly into most serenity API functions + - Because it now implements `AsRef`, `AsRef`, `AsRef`, and `CacheHttp` traits +- Added `execute_modal()` function with support for modal timeouts + +API updates: +- `Modal::create()` gained a `custom_id: String` parameter + - To make it possible to tell apart two modal interactions +- Removed `CreateReply::reference_message(MessageReference)` in favor of `CreateReply::reply(bool)` + - For the unusual case of setting a different reference message than the invocation (why would you? I'm genuinely interested), you can still convert the `CreateReply` into `serenity::CreateMessage` manually via `.to_prefix()` and call `serenity::CreateMessage`'s `reference_message()` method +- Renamed `FrameworkBuilder::user_data_setup()` method to `setup()` +- Renamed `FrameworkOptions::listener` field to `event_handler` +- Renamed `Context::discord()` method to `serenity_context()` + +Behavior changes: +- `register_application_commands_buttons()` now has emojis, reworked wording, and prints the time taken to register +- `Modal::execute()` always responds to the correct modal now +- When a subcommand is invoked, all parent commands' checks are run too, now + +Detailed changelog: https://github.com/kangalioo/poise/compare/v0.4.1...v0.5.0 + # 0.4.1 Behavior changes: diff --git a/Cargo.lock b/Cargo.lock index 29186b3c5454..e89e14fc67a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,17 +61,6 @@ dependencies = [ "syn 2.0.29", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -105,6 +94,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + [[package]] name = "block-buffer" version = "0.10.4" @@ -301,17 +296,38 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" dependencies = [ - "atty", "humantime", + "is-terminal", "log", "regex", "termcolor", ] +[[package]] +name = "errno" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "flate2" version = "1.0.27" @@ -525,15 +541,6 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.3.2" @@ -692,6 +699,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + [[package]] name = "itoa" version = "1.0.9" @@ -713,6 +731,12 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "linux-raw-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" + [[package]] name = "lock_api" version = "0.4.10" @@ -786,7 +810,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.3.2", + "hermit-abi", "libc", ] @@ -848,7 +872,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "poise" -version = "0.4.1" +version = "0.5.5" dependencies = [ "async-trait", "derivative", @@ -863,6 +887,7 @@ dependencies = [ "once_cell", "parking_lot", "poise_macros", + "rand", "regex", "serenity", "tokio", @@ -870,7 +895,7 @@ dependencies = [ [[package]] name = "poise_macros" -version = "0.4.0" +version = "0.5.5" dependencies = [ "darling", "proc-macro2", @@ -938,7 +963,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1039,6 +1064,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ed4fa021d81c8392ce04db050a3da9a60299050b7ae1cf482d862b54a7218f" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustls" version = "0.21.7" @@ -1169,7 +1207,7 @@ dependencies = [ "arrayvec", "async-trait", "base64", - "bitflags", + "bitflags 1.3.2", "bytes", "chrono", "dashmap", diff --git a/Cargo.toml b/Cargo.toml index 1e5dfa50a4df..89a20ea621dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,19 @@ [package] -authors = ["kangalioo "] +authors = ["kangalio "] edition = "2018" name = "poise" -version = "0.4.1" +version = "0.5.5" +rust-version = "1.71.0" description = "A Discord bot framework for serenity" license = "MIT" -repository = "https://github.com/kangalioo/poise/" +repository = "https://github.com/serenity-rs/poise/" [dependencies] -tokio = { version = "1.21.1", default-features = false } # for async in general +tokio = { version = "1.25.1", default-features = false } # for async in general futures-core = { version = "0.3.13", default-features = false } # for async in general futures-util = { version = "0.3.13", default-features = false } # for async in general once_cell = { version = "1.7.2", default-features = false, features = ["std"] } # to store and set user data -poise_macros = { path = "macros", version = "0.4.0" } # remember to update the version on changes! +poise_macros = { path = "macros", version = "0.5.5" } # remember to update the version on changes! async-trait = { version = "0.1.48", default-features = false } # various traits regex = { version = "1.6.0", default-features = false, features = ["std"] } # prefix log = { version = "0.4.14", default-features = false } # warning about weird state @@ -23,7 +24,7 @@ parking_lot = "0.12.1" default-features = false features = ["builder", "client", "gateway", "model", "utils", "collector", "framework"] -# version = "0.11.5" +# version = "0.11.6" git = "https://github.com/serenity-rs/serenity" branch = "next" @@ -32,19 +33,25 @@ branch = "next" [dev-dependencies] # For the examples -tokio = { version = "1.21.1", features = ["rt-multi-thread"] } +tokio = { version = "1.25.1", features = ["rt-multi-thread"] } futures = { version = "0.3.13", default-features = false } -env_logger = "0.9.0" +env_logger = "0.10.0" fluent = "0.16.0" intl-memoizer = "0.5.1" fluent-syntax = "0.11" +rand = "0.8.5" [features] -default = ["serenity/rustls_backend", "cache", "chrono"] +default = ["serenity/rustls_backend", "cache", "chrono", "handle_panics"] chrono = ["serenity/chrono"] cache = ["serenity/cache"] # No-op feature because serenity/collector is now enabled by default collector = [] +# Enables support for handling panics inside commands via FrameworkError::CommandPanic. +# This feature has no overhead and can always be enabled. +# This feature exists because some users want to disable the mere possibility of catching panics at +# build time for peace of mind. +handle_panics = [] [package.metadata.docs.rs] all-features = true diff --git a/README.md b/README.md index 83d9cf9e1e40..5b151c2ba900 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -[![Build](https://img.shields.io/github/workflow/status/kangalioo/poise/CI)](https://kangalioo.github.io/poise/) +[![Build](https://img.shields.io/github/actions/workflow/status/serenity-rs/poise/ci.yml?branch=current)](https://serenity-rs.github.io/poise/) [![crates.io](https://img.shields.io/crates/v/poise.svg)](https://crates.io/crates/poise) [![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://kangalioo.github.io/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.72+](https://img.shields.io/badge/rust-1.72+-93450a) @@ -11,7 +11,7 @@ Poise is an opinionated Discord bot framework with a few distinctive features: - slash commands: completely define both normal and slash commands with a single function - flexible argument parsing: command parameters are defined with normal Rust types and parsed automatically -# [See documentation for more info](https://docs.rs/poise/) +# See [API documentation](https://docs.rs/poise/) and [examples](examples) # Bots using poise @@ -20,9 +20,9 @@ For each bot, there's a list of notable features for you to take inspiration fro - [Dexscreener Pricebot](https://github.com/keiveulbugs/Dexscreener_pricebot) by [@keiveulbugs](https://github.com/keiveulbugs): embeds, API calls, ephemeral messages - [TTS Bot](https://github.com/Discord-TTS/Bot/) by [@GnomedDev](https://github.com/GnomedDev): localization, database, voice - [Scripty](https://github.com/scripty-bot/scripty) by [@tazz4843](https://github.com/tazz4843): localization, database, voice -- [Etternabot](https://github.com/kangalioo/Etternabot) by [@kangalioo](https://github.com/kangalioo): response transformation, variadic and lazy arguments -- [Rustbot](https://github.com/kangalioo/rustbot) by [@kangalioo](https://github.com/kangalioo): database, custom prefixes +- [Etternabot](https://github.com/kangalio/Etternabot) by [@kangalio](https://github.com/kangalio): response transformation, variadic and lazy arguments +- [Rustbot](https://github.com/kangalio/rustbot) by [@kangalio](https://github.com/kangalio): database, custom prefixes -You're welcome to add your own bot [via a PR](https://github.com/kangalioo/poise/compare)! +You're welcome to add your own bot [via a PR](https://github.com/serenity-rs/poise/compare)! -For more projects, see GitHub's [Used By page](https://github.com/kangalioo/poise/network/dependents). +For more projects, see GitHub's [Used By page](https://github.com/serenity-rs/poise/network/dependents). diff --git a/examples/README.md b/examples/README.md index 19db1eb27be3..2501a39c3763 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,3 +5,49 @@ You must set the following environment variables: Application ID and owner ID don't have to be set, since they are requested from Discord on startup by poise. + +You can start any of the examples by running `cargo run` with the `--example` flag, e.g.: + +Unix: + +```bash +export DISCORD_TOKEN="your_token" +cargo run --example=framework_usage +``` + +Windows: + +```powershell +$env:DISCORD_TOKEN="your_token" +cargo run --example=framework_usage +``` + +# basic_structure + +Showcases the basics of poise: `FrameworkOptions`, creating and accessing the data struct, a help +command, defining commands and sending responses. + +# feature_showcase + +Kitchen sink demonstration of most of poise's features. Each file showcases one feature using +one or more example commands. + +# fluent_localization + +Example implementation of localization how it might be suitable for large-scale bots, using the +[Fluent localization framework](https://projectfluent.org/). + +# invocation_data + +Small example to test and demonstrate how `Context.invocation_data` flows through the various stages +of a command invocation. + +# manual_dispatch + +Demonstrates how to circumvent `poise::Framework` and invoke poise's event dispatch functions +manually. This is useful only in special cases. + +# quickstart + +Contains the quickstart code from the crate root docs. It is stored here so that it can be +automatically tested using `cargo check --example quickstart`. diff --git a/examples/advanced_cooldowns/main.rs b/examples/advanced_cooldowns/main.rs new file mode 100644 index 000000000000..7e34ed537fc9 --- /dev/null +++ b/examples/advanced_cooldowns/main.rs @@ -0,0 +1,57 @@ +use poise::serenity_prelude as serenity; + +struct Data {} // User data, which is stored and accessible in all command invocations +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; + +#[poise::command(slash_command, prefix_command)] +async fn dynamic_cooldowns(ctx: Context<'_>) -> Result<(), Error> { + { + let mut cooldown_tracker = ctx.command().cooldowns.lock().unwrap(); + + // You can change the cooldown duration depending on the message author, for example + let mut cooldown_durations = poise::CooldownConfig::default(); + if ctx.author().id == 472029906943868929 { + cooldown_durations.user = Some(std::time::Duration::from_secs(10)); + } + let cooldown_ctx = ctx.cooldown_context(); + + match cooldown_tracker.remaining_cooldown(cooldown_ctx.clone(), &cooldown_durations) { + Some(remaining) => { + return Err(format!("Please wait {} seconds", remaining.as_secs()).into()) + } + None => cooldown_tracker.start_cooldown(cooldown_ctx), + } + }; + + ctx.say("You successfully invoked the command!").await?; + Ok(()) +} + +#[tokio::main] +async fn main() { + serenity::Client::builder( + std::env::var("TOKEN").unwrap(), + serenity::GatewayIntents::non_privileged(), + ) + .framework(poise::Framework::new( + poise::FrameworkOptions { + commands: vec![dynamic_cooldowns()], + // This is important! Or else, the command will be marked as invoked before our custom + // cooldown code has run - even if the command ends up not running! + manual_cooldowns: true, + ..Default::default() + }, + move |ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }, + )) + .await + .unwrap() + .start() + .await + .unwrap(); +} diff --git a/examples/basic_structure/commands.rs b/examples/basic_structure/commands.rs new file mode 100644 index 000000000000..376da639e3c2 --- /dev/null +++ b/examples/basic_structure/commands.rs @@ -0,0 +1,77 @@ +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 +#[poise::command(prefix_command, slash_command)] +pub async fn vote( + ctx: Context<'_>, + #[description = "What to vote for"] choice: String, +) -> Result<(), Error> { + // Lock the Mutex in a block {} so the Mutex isn't locked across an await point + let num_votes = { + let mut hash_map = ctx.data().votes.lock().unwrap(); + let num_votes = hash_map.entry(choice.clone()).or_default(); + *num_votes += 1; + *num_votes + }; + + let response = format!("Successfully voted for {choice}. {choice} now has {num_votes} votes!"); + ctx.say(response).await?; + Ok(()) +} + +/// Retrieve number of votes +/// +/// Retrieve the number of votes either in general, or for a specific choice: +/// ``` +/// ~getvotes +/// ~getvotes pumpkin +/// ``` +#[poise::command(prefix_command, track_edits, aliases("votes"), slash_command)] +pub async fn getvotes( + ctx: Context<'_>, + #[description = "Choice to retrieve votes for"] choice: Option, +) -> Result<(), Error> { + if let Some(choice) = choice { + let num_votes = *ctx.data().votes.lock().unwrap().get(&choice).unwrap_or(&0); + let response = match num_votes { + 0 => format!("Nobody has voted for {} yet", choice), + _ => format!("{} people have voted for {}", num_votes, choice), + }; + ctx.say(response).await?; + } else { + let mut response = String::new(); + for (choice, num_votes) in ctx.data().votes.lock().unwrap().iter() { + response += &format!("{}: {} votes", choice, num_votes); + } + + if response.is_empty() { + response += "Nobody has voted for anything yet :("; + } + + ctx.say(response).await?; + }; + + Ok(()) +} diff --git a/examples/framework_usage/main.rs b/examples/basic_structure/main.rs similarity index 57% rename from examples/framework_usage/main.rs rename to examples/basic_structure/main.rs index 9dd8c9e64f88..fb8317840a18 100644 --- a/examples/framework_usage/main.rs +++ b/examples/basic_structure/main.rs @@ -1,7 +1,6 @@ #![warn(clippy::str_to_string)] mod commands; -use commands::*; use poise::serenity_prelude as serenity; use std::{collections::HashMap, env::var, sync::Mutex, time::Duration}; @@ -15,43 +14,13 @@ pub struct Data { votes: Mutex>, } -/// Show this help menu -#[poise::command(prefix_command, track_edits, slash_command)] -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", - show_context_menu_commands: true, - ..Default::default() - }, - ) - .await?; - Ok(()) -} - -/// Registers or unregisters application commands in this guild or globally -#[poise::command(prefix_command, hide_in_help)] -async fn register(ctx: Context<'_>) -> Result<(), Error> { - poise::builtins::register_application_commands_buttons(ctx).await?; - - Ok(()) -} - async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { // This is our custom error handler // They are many errors that can occur, so we only handle the ones we want to customize // and forward the rest to the default handler match error { poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {:?}", error), - poise::FrameworkError::Command { error, ctx } => { + poise::FrameworkError::Command { error, ctx, .. } => { println!("Error in command `{}`: {:?}", ctx.command().name, error,); } error => { @@ -66,42 +35,10 @@ async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { async fn main() { env_logger::init(); + // 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![ - help(), - register(), - general::vote(), - general::getvotes(), - general::addmultiple(), - general::choice(), - general::boop(), - general::voiceinfo(), - general::test_reuse_response(), - general::oracle(), - general::code(), - general::say(), - general::file_details(), - general::totalsize(), - general::modal(), - general::punish(), - #[cfg(feature = "cache")] - general::servers(), - general::reply(), - context_menu::user_info(), - context_menu::echo(), - autocomplete::greet(), - checks::shutdown(), - checks::modonly(), - checks::delete(), - checks::ferrisparty(), - checks::add(), - checks::get_guild_name(), - checks::only_in_dms(), - checks::lennyface(), - checks::permissions_v2(), - subcommands::parent(), - localization::welcome(), - ], + commands: vec![commands::help(), commands::vote(), commands::getvotes()], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("~".into()), edit_tracker: Some(poise::EditTracker::for_timespan(Duration::from_secs(3600))), @@ -134,16 +71,24 @@ async fn main() { Ok(true) }) }), - listener: |event, _framework, _data| { + /// 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: |event, _framework, _data| { Box::pin(async move { - println!("Got an event in listener: {:?}", event.snake_case_name()); + println!( + "Got an event in event handler: {:?}", + event.snake_case_name() + ); Ok(()) }) }, ..Default::default() }; - let framework = poise::Framework::new(options, move |_ctx, _ready, _framework| { + let framework = poise::Framework::new(options, move |ctx, ready, framework| { Box::pin(async move { + println!("Logged in as {}", ready.user.name); + poise::builtins::register_globally(ctx, &framework.options().commands).await?; Ok(Data { votes: Mutex::new(HashMap::new()), }) diff --git a/examples/feature_showcase/attachment_parameter.rs b/examples/feature_showcase/attachment_parameter.rs new file mode 100644 index 000000000000..f99ef736ffba --- /dev/null +++ b/examples/feature_showcase/attachment_parameter.rs @@ -0,0 +1,36 @@ +use crate::{Context, Error}; +use poise::serenity_prelude as serenity; + +/// View the difference between two file sizes +#[poise::command(prefix_command, slash_command)] +pub async fn file_details( + ctx: Context<'_>, + #[description = "File to examine"] file: serenity::Attachment, + #[description = "Second file to examine"] file_2: Option, +) -> Result<(), Error> { + ctx.say(format!( + "First file name: **{}**. File size difference: **{}** bytes", + file.filename, + file.size - file_2.map_or(0, |f| f.size) + )) + .await?; + Ok(()) +} + +#[poise::command(prefix_command)] +pub async fn totalsize( + ctx: Context<'_>, + #[description = "File to rename"] files: Vec, +) -> Result<(), Error> { + // we cast to u64 here as (10 files times 500mb each) > u32::MAX (~4.2gb) + let total = files.iter().map(|f| f.size as u64).sum::(); + + ctx.say(format!( + "Total file size: `{}B`. Average size: `{}B`", + total, + total.checked_div(files.len() as _).unwrap_or(0) + )) + .await?; + + Ok(()) +} diff --git a/examples/framework_usage/commands/autocomplete.rs b/examples/feature_showcase/autocomplete.rs similarity index 94% rename from examples/framework_usage/commands/autocomplete.rs rename to examples/feature_showcase/autocomplete.rs index 90656b725d60..5896db3d6122 100644 --- a/examples/framework_usage/commands/autocomplete.rs +++ b/examples/feature_showcase/autocomplete.rs @@ -36,15 +36,15 @@ async fn autocomplete_number( _partial: &str, ) -> impl Iterator> { // Dummy choices - [1_u32, 2, 3, 4, 5] - .iter() - .map(|&n| poise::AutocompleteChoice { - name: format!( + [1_u32, 2, 3, 4, 5].iter().map(|&n| { + poise::AutocompleteChoice::new_with_value( + format!( "{} (why did discord even give autocomplete choices separate labels)", n ), - value: n, - }) + n, + ) + }) } /// Greet a user. Showcasing autocomplete! diff --git a/examples/feature_showcase/bool_parameter.rs b/examples/feature_showcase/bool_parameter.rs new file mode 100644 index 000000000000..3a4ada220231 --- /dev/null +++ b/examples/feature_showcase/bool_parameter.rs @@ -0,0 +1,19 @@ +use crate::{Context, Error}; + +/// Tests poise's bool parameter +/// +/// In prefix commands, many affirmative words and their opposites are supported +#[poise::command(slash_command, prefix_command)] +pub async fn oracle( + ctx: Context<'_>, + #[description = "Take a decision"] b: bool, +) -> Result<(), Error> { + if b { + ctx.say("You seem to be an optimistic kind of person...") + .await?; + } else { + ctx.say("You seem to be a pessimistic kind of person...") + .await?; + } + Ok(()) +} diff --git a/examples/feature_showcase/builtins.rs b/examples/feature_showcase/builtins.rs new file mode 100644 index 000000000000..a811f9614918 --- /dev/null +++ b/examples/feature_showcase/builtins.rs @@ -0,0 +1,18 @@ +use crate::{Context, Error}; + +#[cfg(feature = "cache")] +#[poise::command(slash_command, prefix_command)] +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(()) +} diff --git a/examples/framework_usage/commands/checks.rs b/examples/feature_showcase/checks.rs similarity index 76% rename from examples/framework_usage/commands/checks.rs rename to examples/feature_showcase/checks.rs index 152759568c52..2d32b8e7f6b4 100644 --- a/examples/framework_usage/commands/checks.rs +++ b/examples/feature_showcase/checks.rs @@ -39,14 +39,14 @@ pub async fn delete( ctx: Context<'_>, #[description = "Message to be deleted"] msg: serenity::Message, ) -> Result<(), Error> { - msg.delete(ctx.discord()).await?; + msg.delete(ctx).await?; Ok(()) } /// Returns true if username is Ferris async fn is_ferris(ctx: Context<'_>) -> Result { let nickname = match ctx.guild_id() { - Some(guild_id) => ctx.author().nick_in(ctx.discord(), guild_id).await, + Some(guild_id) => ctx.author().nick_in(ctx, guild_id).await, None => None, }; let name = nickname.as_ref().unwrap_or(&ctx.author().name); @@ -93,16 +93,39 @@ pub async fn ferrisparty(ctx: Context<'_>) -> Result<(), Error> { channel_cooldown = 2, member_cooldown = 3, )] -pub async fn add( +pub async fn cooldowns(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You successfully called the command").await?; + Ok(()) +} + +/// Overrides the user cooldown for a specific user +async fn dynamic_cooldown_check(ctx: Context<'_>) -> Result { + let mut cooldown_durations = ctx.command().cooldown_config.lock().unwrap(); + + // You can change the cooldown duration depending on the message author, for example + if ctx.author().id == 472029906943868929 { + cooldown_durations.user = Some(std::time::Duration::from_secs(10)); + } else { + cooldown_durations.user = None + } + + Ok(true) +} + +#[poise::command(prefix_command, slash_command, check = "dynamic_cooldown_check")] +pub async fn dynamic_cooldowns(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You successfully called the command").await?; + Ok(()) +} + +#[poise::command(prefix_command, slash_command)] +pub async fn minmax( ctx: Context<'_>, - #[description = "First operand"] a: f64, - #[description = "Second operand"] #[min = -15] #[max = 28.765] - b: f32, + value: f32, ) -> Result<(), Error> { - ctx.say(format!("Result: {}", a + b as f64)).await?; - + ctx.say(format!("You submitted number {}", value)).await?; Ok(()) } diff --git a/examples/feature_showcase/choice_parameter.rs b/examples/feature_showcase/choice_parameter.rs new file mode 100644 index 000000000000..89c5b72d9fe8 --- /dev/null +++ b/examples/feature_showcase/choice_parameter.rs @@ -0,0 +1,22 @@ +use crate::{Context, Error}; + +#[derive(Debug, poise::ChoiceParameter)] +pub enum MyStringChoice { + #[name = "The first choice"] + A, + #[name = "The second choice"] + #[name = "A single choice can have multiple names"] + B, + // If no name is given, the variant name is used + C, +} + +/// Dummy command to test slash command choice parameters +#[poise::command(prefix_command, slash_command)] +pub async fn choice( + ctx: Context<'_>, + #[description = "The choice you want to choose"] choice: MyStringChoice, +) -> Result<(), Error> { + ctx.say(format!("You entered {:?}", choice)).await?; + Ok(()) +} diff --git a/examples/feature_showcase/code_block_parameter.rs b/examples/feature_showcase/code_block_parameter.rs new file mode 100644 index 000000000000..d6236a6ffc7f --- /dev/null +++ b/examples/feature_showcase/code_block_parameter.rs @@ -0,0 +1,12 @@ +use crate::{Context, Error}; + +#[poise::command(prefix_command)] +pub async fn code( + ctx: Context<'_>, + args: poise::KeyValueArgs, + code: poise::CodeBlock, +) -> Result<(), Error> { + ctx.say(format!("Key value args: {:?}\nCode: {}", args, code)) + .await?; + Ok(()) +} diff --git a/examples/feature_showcase/collector.rs b/examples/feature_showcase/collector.rs new file mode 100644 index 000000000000..d417152bec8c --- /dev/null +++ b/examples/feature_showcase/collector.rs @@ -0,0 +1,43 @@ +use crate::{Context, Error}; +use poise::serenity_prelude as serenity; + +/// Boop the bot! +#[poise::command(prefix_command, track_edits, slash_command)] +pub async fn boop(ctx: Context<'_>) -> Result<(), Error> { + let uuid_boop = ctx.id(); + + ctx.send( + poise::CreateReply::default() + .content("I want some boops!") + .components(vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new(uuid_boop.to_string()) + .label("Boop me!") + .style(serenity::ButtonStyle::Primary), + ])]), + ) + .await?; + + let mut boop_count = 0; + while let Some(mci) = serenity::ComponentInteractionCollector::new(ctx) + .author_id(ctx.author().id) + .channel_id(ctx.channel_id()) + .timeout(std::time::Duration::from_secs(120)) + .filter(move |mci| mci.data.custom_id == uuid_boop.to_string()) + .await + { + boop_count += 1; + + let mut msg = mci.message.clone(); + + msg.edit( + ctx, + serenity::EditMessage::default().content(format!("Boop count: {}", boop_count)), + ) + .await?; + + mci.create_response(ctx, serenity::CreateInteractionResponse::Acknowledge) + .await?; + } + + Ok(()) +} diff --git a/examples/framework_usage/commands/context_menu.rs b/examples/feature_showcase/context_menu.rs similarity index 100% rename from examples/framework_usage/commands/context_menu.rs rename to examples/feature_showcase/context_menu.rs diff --git a/examples/feature_showcase/inherit_checks.rs b/examples/feature_showcase/inherit_checks.rs new file mode 100644 index 000000000000..1592c5f6da2e --- /dev/null +++ b/examples/feature_showcase/inherit_checks.rs @@ -0,0 +1,38 @@ +use crate::{Context, Error}; + +async fn child2_check(_ctx: Context<'_>) -> Result { + println!("Child2 check executed!"); + Ok(true) +} +async fn child1_check(_ctx: Context<'_>) -> Result { + println!("Child1 check executed!"); + Ok(true) +} +async fn parent_check(_ctx: Context<'_>) -> Result { + println!("Parent check executed!"); + Ok(true) +} + +#[poise::command(slash_command, prefix_command, check = "child2_check")] +async fn child2(ctx: Context<'_>, _b: bool, _s: String, _i: u32) -> Result<(), Error> { + ctx.say(ctx.invocation_string()).await?; + Ok(()) +} +#[poise::command( + slash_command, + prefix_command, + subcommands("child2"), + check = "child1_check" +)] +async fn child1(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} +#[poise::command( + slash_command, + prefix_command, + subcommands("child1"), + check = "parent_check" +)] +pub async fn parent_checks(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/examples/framework_usage/commands/localization.rs b/examples/feature_showcase/localization.rs similarity index 67% rename from examples/framework_usage/commands/localization.rs rename to examples/feature_showcase/localization.rs index 50fede19cdb5..3616d73efadd 100644 --- a/examples/framework_usage/commands/localization.rs +++ b/examples/feature_showcase/localization.rs @@ -9,18 +9,28 @@ pub enum WelcomeChoice { "de", "Willkommen auf unserem coolen Server! Frag mich, falls du Hilfe brauchst" )] + #[name_localized( + "es-ES", + "¡Bienvenido a nuestro genial servidor! Pregúntame si necesitas ayuda" + )] A, #[name = "Welcome to the club, you're now a good person. Well, I hope."] #[name_localized( "de", "Willkommen im Club, du bist jetzt ein guter Mensch. Naja, hoffentlich." )] + #[name_localized( + "es-ES", + "Bienvenido al club, ahora eres una buena persona. Bueno, eso espero." + )] B, #[name = "I hope that you brought a controller to play together!"] #[name_localized("de", "Ich hoffe du hast einen Controller zum Spielen mitgebracht!")] + #[name_localized("es-ES", "Espero que hayas traído un mando para jugar juntos.")] C, #[name = "Hey, do you want a coffee?"] #[name_localized("de", "Hey, willst du einen Kaffee?")] + #[name_localized("es-ES", "Oye, ¿Quieres un café?")] D, } @@ -28,16 +38,22 @@ pub enum WelcomeChoice { #[poise::command( slash_command, name_localized("de", "begrüßen"), - description_localized("de", "Einen Nutzer begrüßen") + name_localized("es-ES", "saludar"), + description_localized("de", "Einen Nutzer begrüßen"), + description_localized("es-ES", "Saludar a un usuario") )] pub async fn welcome( ctx: Context<'_>, #[name_localized("de", "nutzer")] #[description_localized("de", "Der zu begrüßende Nutzer")] + #[name_localized("es-ES", "usuario")] + #[description_localized("es-ES", "El usuario a saludar")] #[description = "The user to welcome"] user: serenity::User, #[name_localized("de", "nachricht")] #[description_localized("de", "Die versendete Nachricht")] + #[name_localized("es-ES", "mensaje")] + #[description_localized("es-ES", "El mensaje enviado")] #[description = "The message to send"] message: WelcomeChoice, ) -> Result<(), Error> { diff --git a/examples/feature_showcase/main.rs b/examples/feature_showcase/main.rs new file mode 100644 index 000000000000..69f8a1974bdf --- /dev/null +++ b/examples/feature_showcase/main.rs @@ -0,0 +1,112 @@ +mod attachment_parameter; +mod autocomplete; +mod bool_parameter; +mod builtins; +mod checks; +mod choice_parameter; +mod code_block_parameter; +mod collector; +mod context_menu; +mod inherit_checks; +mod localization; +mod modal; +mod paginate; +mod panic_handler; +mod parameter_attributes; +mod raw_identifiers; +mod response_with_reply; +mod subcommand_required; +mod subcommands; +mod track_edits; + +use poise::serenity_prelude as serenity; + +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; +// User data, which is stored and accessible in all command invocations +pub struct Data {} + +#[tokio::main] +async fn main() { + let options = poise::FrameworkOptions { + commands: vec![ + attachment_parameter::file_details(), + attachment_parameter::totalsize(), + autocomplete::greet(), + bool_parameter::oracle(), + #[cfg(feature = "cache")] + builtins::servers(), + builtins::help(), + checks::shutdown(), + checks::modonly(), + checks::delete(), + checks::ferrisparty(), + checks::cooldowns(), + checks::dynamic_cooldowns(), + checks::minmax(), + checks::get_guild_name(), + checks::only_in_dms(), + checks::lennyface(), + checks::permissions_v2(), + choice_parameter::choice(), + code_block_parameter::code(), + collector::boop(), + context_menu::user_info(), + context_menu::echo(), + inherit_checks::parent_checks(), + localization::welcome(), + modal::modal(), + modal::component_modal(), + paginate::paginate(), + panic_handler::div(), + parameter_attributes::addmultiple(), + parameter_attributes::voiceinfo(), + parameter_attributes::say(), + parameter_attributes::punish(), + parameter_attributes::stringlen(), + // raw_identifiers::r#move(), // Currently doesn't work (issue #170) + response_with_reply::reply(), + subcommands::parent(), + subcommand_required::parent_subcommand_required(), + track_edits::test_reuse_response(), + track_edits::add(), + ], + prefix_options: poise::PrefixFrameworkOptions { + prefix: Some("~".into()), + ..Default::default() + }, + on_error: |error| { + Box::pin(async move { + match error { + poise::FrameworkError::ArgumentParse { error, .. } => { + if let Some(error) = error.downcast_ref::() { + println!("Found a RoleParseError: {:?}", error); + } else { + println!("Not a RoleParseError :("); + } + } + other => poise::builtins::on_error(other).await.unwrap(), + } + }) + }, + ..Default::default() + }; + serenity::Client::builder( + std::env::var("DISCORD_TOKEN").unwrap(), + serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT, + ) + .framework(poise::Framework::new( + options, + move |ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }, + )) + .await + .unwrap() + .start() + .await + .unwrap(); +} diff --git a/examples/feature_showcase/modal.rs b/examples/feature_showcase/modal.rs new file mode 100644 index 000000000000..b86330a25021 --- /dev/null +++ b/examples/feature_showcase/modal.rs @@ -0,0 +1,54 @@ +use poise::serenity_prelude as serenity; + +use crate::{Data, Error}; + +#[derive(Debug, poise::Modal)] +#[allow(dead_code)] // fields only used for Debug print +struct MyModal { + first_input: String, + second_input: Option, +} +#[poise::command(slash_command)] +pub async fn modal(ctx: poise::ApplicationContext<'_, Data, Error>) -> Result<(), Error> { + use poise::Modal as _; + + let data = MyModal::execute(ctx).await?; + println!("Got data: {:?}", data); + + Ok(()) +} + +/// Tests the Modal trait with component interactions. +/// +/// Should be both prefix and slash to make sure it works without any slash command interaction +/// present. +#[poise::command(prefix_command, slash_command)] +pub async fn component_modal(ctx: crate::Context<'_>) -> Result<(), Error> { + ctx.send( + poise::CreateReply::default() + .content("Click the button below to open the modal") + .components(vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new("open_modal") + .label("Open modal") + .style(serenity::ButtonStyle::Success), + ])]), + ) + .await?; + + while let Some(mci) = + poise::serenity_prelude::ComponentInteractionCollector::new(ctx.serenity_context()) + .timeout(std::time::Duration::from_secs(120)) + .filter(move |mci| mci.data.custom_id == "open_modal") + .await + { + let data = poise::execute_modal_on_component_interaction::( + ctx, + std::sync::Arc::new(mci), + None, + None, + ) + .await?; + println!("Got data: {:?}", data); + } + Ok(()) +} diff --git a/examples/feature_showcase/paginate.rs b/examples/feature_showcase/paginate.rs new file mode 100644 index 000000000000..7b291f804f76 --- /dev/null +++ b/examples/feature_showcase/paginate.rs @@ -0,0 +1,15 @@ +use crate::{Context, Error}; + +#[poise::command(slash_command, prefix_command)] +pub async fn paginate(ctx: Context<'_>) -> Result<(), Error> { + let pages = &[ + "Content of first page", + "Content of second page", + "Content of third page", + "Content of fourth page", + ]; + + poise::samples::paginate(ctx, pages).await?; + + Ok(()) +} diff --git a/examples/feature_showcase/panic_handler.rs b/examples/feature_showcase/panic_handler.rs new file mode 100644 index 000000000000..471b9c088895 --- /dev/null +++ b/examples/feature_showcase/panic_handler.rs @@ -0,0 +1,10 @@ +use crate::{Context, Error}; + +/// This command panics when dividing by zero +/// +/// This will be caught by poise's panic handler +#[poise::command(slash_command, prefix_command)] +pub async fn div(ctx: Context<'_>, a: i32, b: i32) -> Result<(), Error> { + ctx.say((a / b).to_string()).await?; + Ok(()) +} diff --git a/examples/feature_showcase/parameter_attributes.rs b/examples/feature_showcase/parameter_attributes.rs new file mode 100644 index 000000000000..2b3c0594daea --- /dev/null +++ b/examples/feature_showcase/parameter_attributes.rs @@ -0,0 +1,100 @@ +use crate::{Context, Error}; +use poise::serenity_prelude as serenity; + +/// Adds multiple numbers +/// +/// Demonstrates `#[min]` and `#[max]` +#[poise::command(prefix_command, slash_command)] +pub async fn addmultiple( + ctx: Context<'_>, + #[description = "An operand"] a: i8, + #[description = "An operand"] b: u64, + #[description = "An operand"] + #[min = 1234567890123456_i64] + #[max = 1234567890987654_i64] + c: i64, +) -> Result<(), Error> { + ctx.say(format!("Result: {}", a as i128 + b as i128 + c as i128)) + .await?; + + Ok(()) +} + +/// Demonstrates `#[channel_types]` +#[poise::command(slash_command)] +pub async fn voiceinfo( + ctx: Context<'_>, + #[description = "Information about a server voice channel"] + #[channel_types("Voice")] + channel: serenity::GuildChannel, +) -> Result<(), Error> { + let response = format!( + "\ +**Name**: {} +**Bitrate**: {} +**User limit**: {} +**RTC region**: {} +**Video quality mode**: {:?}", + channel.name, + channel.bitrate.unwrap_or_default(), + channel.user_limit.unwrap_or_default(), + channel.rtc_region.unwrap_or_default(), + channel + .video_quality_mode + .unwrap_or(serenity::VideoQualityMode::Unknown(0)) + ); + + ctx.say(response).await?; + Ok(()) +} + +/// Echoes the string you give it +/// +/// Demonstrates `#[rest]` +#[poise::command(prefix_command, slash_command)] +pub async fn say( + ctx: Context<'_>, + #[rest] + #[description = "Text to say"] + msg: String, +) -> Result<(), Error> { + ctx.say(msg).await?; + Ok(()) +} + +#[derive(Debug, poise::ChoiceParameter)] +pub enum PunishType { + Ban, + Kick, + Mute, +} + +/// Punishment command for testing the rename macro +#[poise::command(slash_command)] +pub async fn punish( + ctx: Context<'_>, + #[description = "Punishment type"] + #[rename = "type"] + punish_type: PunishType, + #[description = "User to execute the punishment on"] user: serenity::User, +) -> Result<(), Error> { + let text = match punish_type { + PunishType::Ban => format!("{} has been banned!", user.name), + PunishType::Kick => format!("{} has been kicked!", user.name), + PunishType::Mute => format!("{} has been muted!", user.name), + }; + ctx.say(text).await?; + + Ok(()) +} + +#[poise::command(slash_command)] +pub async fn stringlen( + ctx: Context<'_>, + #[min_length = 3] + #[max_length = 5] + s: String, +) -> Result<(), Error> { + ctx.say(format!("you wrote: {}", s)).await?; + Ok(()) +} diff --git a/examples/feature_showcase/raw_identifiers.rs b/examples/feature_showcase/raw_identifiers.rs new file mode 100644 index 000000000000..1432b50d3d01 --- /dev/null +++ b/examples/feature_showcase/raw_identifiers.rs @@ -0,0 +1,8 @@ +use crate::{Context, Error}; + +#[poise::command(prefix_command, slash_command)] +pub async fn r#move(ctx: Context<'_>, r#loop: String, r#fn: String) -> Result<(), Error> { + ctx.say(format!("called with loop={} and fn={}", r#loop, r#fn)) + .await?; + Ok(()) +} diff --git a/examples/feature_showcase/response_with_reply.rs b/examples/feature_showcase/response_with_reply.rs new file mode 100644 index 000000000000..ac093ead9a53 --- /dev/null +++ b/examples/feature_showcase/response_with_reply.rs @@ -0,0 +1,7 @@ +use crate::{Context, Error}; + +#[poise::command(slash_command, prefix_command)] +pub async fn reply(ctx: Context<'_>) -> Result<(), Error> { + ctx.reply(format!("Hello {}!", ctx.author().name)).await?; + Ok(()) +} diff --git a/examples/feature_showcase/subcommand_required.rs b/examples/feature_showcase/subcommand_required.rs new file mode 100644 index 000000000000..20cb5ffe28c1 --- /dev/null +++ b/examples/feature_showcase/subcommand_required.rs @@ -0,0 +1,34 @@ +use crate::{Context, Error}; + +/// A command with two subcommands: `child1` and `child2` +/// +/// Running this function directly, without any subcommand, is only supported in prefix commands. +/// Discord doesn't permit invoking the root command of a slash command if it has subcommands. +/// This command can be invoked only with `parent child1` and `parent child2`, due to `subcommand_required` parameter. +/// If you want to allow `parent` to be invoked without subcommand, remove `subcommand_required` parameter +#[poise::command( + prefix_command, + slash_command, + subcommands("child1", "child2"), + subcommand_required +)] +// Omit 'ctx' parameter here. It is not needed, because this function will never be called. +// TODO: Add a way to remove 'ctx' parameter, when `subcommand_required` is set +pub async fn parent_subcommand_required(_: Context<'_>) -> Result<(), Error> { + // This will never be called, because `subcommand_required` parameter is set + Ok(()) +} + +/// A subcommand of `parent` +#[poise::command(prefix_command, slash_command)] +pub async fn child1(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You invoked the first child command!").await?; + Ok(()) +} + +/// Another subcommand of `parent` +#[poise::command(prefix_command, slash_command)] +pub async fn child2(ctx: Context<'_>) -> Result<(), Error> { + ctx.say("You invoked the second child command!").await?; + Ok(()) +} diff --git a/examples/framework_usage/commands/subcommands.rs b/examples/feature_showcase/subcommands.rs similarity index 100% rename from examples/framework_usage/commands/subcommands.rs rename to examples/feature_showcase/subcommands.rs diff --git a/examples/feature_showcase/track_edits.rs b/examples/feature_showcase/track_edits.rs new file mode 100644 index 000000000000..8971388b8179 --- /dev/null +++ b/examples/feature_showcase/track_edits.rs @@ -0,0 +1,56 @@ +use crate::{Context, Error}; +use poise::serenity_prelude as serenity; + +#[poise::command(slash_command, prefix_command, reuse_response)] +pub async fn test_reuse_response(ctx: Context<'_>) -> Result<(), Error> { + let image_url = "https://raw.githubusercontent.com/serenity-rs/serenity/current/logo.png"; + + ctx.send( + poise::CreateReply::default() + .content("message 1") + .embed( + serenity::CreateEmbed::default() + .description("embed 1") + .image(image_url), + ) + .components(vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new("1") + .label("button 1") + .style(serenity::ButtonStyle::Primary), + ])]), + ) + .await?; + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let image_url = "https://raw.githubusercontent.com/serenity-rs/serenity/current/examples/e09_create_message_builder/ferris_eyes.png"; + ctx.send( + poise::CreateReply::default() + .content("message 2") + .embed( + serenity::CreateEmbed::default() + .description("embed 2") + .image(image_url), + ) + .components(vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new("2") + .label("button 2") + .style(serenity::ButtonStyle::Danger), + ])]), + ) + .await?; + + Ok(()) +} + +/// Add two numbers +#[poise::command(prefix_command, track_edits, slash_command)] +pub async fn add( + ctx: Context<'_>, + #[description = "First operand"] a: f64, + #[description = "Second operand"] b: f32, +) -> Result<(), Error> { + ctx.say(format!("Result: {}", a + b as f64)).await?; + + Ok(()) +} diff --git a/examples/fluent_localization/main.rs b/examples/fluent_localization/main.rs index 085eb3099800..30b8455d2f19 100644 --- a/examples/fluent_localization/main.rs +++ b/examples/fluent_localization/main.rs @@ -24,6 +24,8 @@ pub async fn welcome( user: serenity::User, message: WelcomeChoice, ) -> Result<(), Error> { + use poise::ChoiceParameter as _; + ctx.say(format!("<@{}> {}", user.id.0, tr!(ctx, message.name()))) .await?; Ok(()) diff --git a/examples/fluent_localization/translation.rs b/examples/fluent_localization/translation.rs index ac2b9860aa9b..e9bb19128b39 100644 --- a/examples/fluent_localization/translation.rs +++ b/examples/fluent_localization/translation.rs @@ -117,17 +117,23 @@ pub fn apply_translations( ); for parameter in &mut command.parameters { + // Skip parameters with no name + let parameter_name = match ¶meter.name { + Some(x) => x, + None => continue, + }; + // Insert localized parameter name and description parameter.name_localizations.insert( locale.clone(), - format(bundle, &command.name, Some(¶meter.name), None).unwrap(), + format(bundle, &command.name, Some(¶meter_name), None).unwrap(), ); parameter.description_localizations.insert( locale.clone(), format( bundle, &command.name, - Some(&format!("{}-description", parameter.name)), + Some(&format!("{}-description", parameter_name)), None, ) .unwrap(), @@ -157,13 +163,20 @@ pub fn apply_translations( Some(format(bundle, &command.name, Some("description"), None).unwrap()); for parameter in &mut command.parameters { + // Skip parameters with no name + let parameter_name = match parameter.name.clone() { + Some(x) => x, + None => continue, + }; + // Set fallback parameter name and description to en-US - parameter.name = format(bundle, &command.name, Some(¶meter.name), None).unwrap(); + parameter.name = + Some(format(bundle, &command.name, Some(¶meter_name), None).unwrap()); parameter.description = Some( format( bundle, &command.name, - Some(&format!("{}-description", parameter.name)), + Some(&format!("{}-description", parameter_name)), None, ) .unwrap(), diff --git a/examples/framework_usage/commands/general.rs b/examples/framework_usage/commands/general.rs deleted file mode 100644 index 5e3e0594b56c..000000000000 --- a/examples/framework_usage/commands/general.rs +++ /dev/null @@ -1,346 +0,0 @@ -use crate::{Context, Error}; -use poise::serenity_prelude as serenity; -use std::fmt::Write as _; - -/// Vote for something -/// -/// Enter `~vote pumpkin` to vote for pumpkins -#[poise::command(prefix_command, slash_command)] -pub async fn vote( - ctx: Context<'_>, - #[description = "What to vote for"] choice: String, -) -> Result<(), Error> { - let num_votes = { - let mut hash_map = ctx.data().votes.lock().unwrap(); - let num_votes = hash_map.entry(choice.clone()).or_default(); - *num_votes += 1; - *num_votes - }; - - let response = format!( - "Successfully voted for {0}. {0} now has {1} votes!", - choice, num_votes - ); - ctx.say(response).await?; - Ok(()) -} - -/// Retrieve number of votes -/// -/// Retrieve the number of votes either in general, or for a specific choice: -/// ``` -/// ~getvotes -/// ~getvotes pumpkin -/// ``` -#[poise::command(prefix_command, track_edits, aliases("votes"), slash_command)] -pub async fn getvotes( - ctx: Context<'_>, - #[description = "Choice to retrieve votes for"] choice: Option, -) -> Result<(), Error> { - if let Some(choice) = choice { - let num_votes = *ctx.data().votes.lock().unwrap().get(&choice).unwrap_or(&0); - let response = match num_votes { - 0 => format!("Nobody has voted for {} yet", choice), - _ => format!("{} people have voted for {}", num_votes, choice), - }; - ctx.say(response).await?; - } else { - let mut response = String::new(); - for (choice, num_votes) in ctx.data().votes.lock().unwrap().iter() { - let _ = writeln!(response, "{}: {} votes", choice, num_votes); - } - - if response.is_empty() { - response += "Nobody has voted for anything yet :("; - } - - ctx.say(response).await?; - }; - - Ok(()) -} - -/// Adds multiple numbers -#[poise::command(prefix_command, slash_command)] -pub async fn addmultiple( - ctx: Context<'_>, - #[description = "An operand"] a: i8, - #[description = "An operand"] b: u64, - #[description = "An operand"] - #[min = 1234567890123456_i64] - #[max = 1234567890987654_i64] - c: i64, -) -> Result<(), Error> { - ctx.say(format!("Result: {}", a as i128 + b as i128 + c as i128)) - .await?; - - Ok(()) -} - -#[derive(Debug, poise::ChoiceParameter)] -pub enum MyStringChoice { - #[name = "The first choice"] - A, - #[name = "The second choice"] - #[name = "A single choice can have multiple names"] - B, - // If no name is given, the variant name is used - C, -} - -/// Dummy command to test slash command choice parameters -#[poise::command(prefix_command, slash_command)] -pub async fn choice( - ctx: Context<'_>, - #[description = "The choice you want to choose"] choice: MyStringChoice, -) -> Result<(), Error> { - ctx.say(format!("You entered {:?}", choice)).await?; - Ok(()) -} - -/// Boop the bot! -#[poise::command(prefix_command, track_edits, slash_command)] -pub async fn boop(ctx: Context<'_>) -> Result<(), Error> { - let uuid_boop = ctx.id(); - - ctx.send( - poise::CreateReply::default() - .content("I want some boops!") - .components(vec![serenity::CreateActionRow::Buttons(vec![ - serenity::CreateButton::new(uuid_boop.to_string()) - .label("Boop!") - .style(serenity::ButtonStyle::Primary), - ])]), - ) - .await?; - - let mut boop_count = 0; - while let Some(mci) = serenity::ComponentInteractionCollector::new(&ctx.discord().shard) - .author_id(ctx.author().id) - .channel_id(ctx.channel_id()) - .timeout(std::time::Duration::from_secs(120)) - .filter(move |mci| mci.data.custom_id == uuid_boop.to_string()) - .await - { - boop_count += 1; - - let mut msg = mci.message.clone(); - msg.edit( - ctx.discord(), - serenity::EditMessage::default().content(format!("Boop count: {}", boop_count)), - ) - .await?; - - mci.create_response( - ctx.discord(), - serenity::CreateInteractionResponse::Acknowledge, - ) - .await?; - } - - Ok(()) -} - -#[poise::command(slash_command)] -pub async fn voiceinfo( - ctx: Context<'_>, - #[description = "Information about a server voice channel"] - #[channel_types("Voice")] - channel: serenity::GuildChannel, -) -> Result<(), Error> { - let response = format!( - "\ -**Name**: {} -**Bitrate**: {} -**User limit**: {} -**RTC region**: {} -**Video quality mode**: {:?}", - channel.name, - channel.bitrate.unwrap_or_default(), - channel.user_limit.unwrap_or_default(), - channel.rtc_region.unwrap_or_default(), - channel - .video_quality_mode - .unwrap_or(serenity::VideoQualityMode::Unknown(0)) - ); - - ctx.say(response).await?; - Ok(()) -} - -#[poise::command(slash_command, prefix_command, reuse_response)] -pub async fn test_reuse_response(ctx: Context<'_>) -> Result<(), Error> { - let image_url = "https://raw.githubusercontent.com/serenity-rs/serenity/current/logo.png"; - - ctx.send( - poise::CreateReply::default() - .content("message 1") - .embed( - serenity::CreateEmbed::default() - .description("embed 1") - .image(image_url), - ) - .components(vec![serenity::CreateActionRow::Buttons(vec![ - serenity::CreateButton::new("1") - .label("button 1") - .style(serenity::ButtonStyle::Primary), - ])]), - ) - .await?; - - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - - let image_url = "https://raw.githubusercontent.com/serenity-rs/serenity/current/examples/e09_create_message_builder/ferris_eyes.png"; - - ctx.send( - poise::CreateReply::default() - .content("message 2") - .embed( - serenity::CreateEmbed::default() - .description("embed 2") - .image(image_url), - ) - .components(vec![serenity::CreateActionRow::Buttons(vec![ - serenity::CreateButton::new("2") - .label("button 2") - .style(serenity::ButtonStyle::Primary), - ])]), - ) - .await?; - - Ok(()) -} - -#[poise::command(slash_command, prefix_command)] -pub async fn oracle( - ctx: Context<'_>, - #[description = "Take a decision"] b: bool, -) -> Result<(), Error> { - if b { - ctx.say("You seem to be an optimistic kind of person...") - .await?; - } else { - ctx.say("You seem to be a pessimistic kind of person...") - .await?; - } - Ok(()) -} - -#[poise::command(prefix_command)] -pub async fn code( - ctx: Context<'_>, - args: poise::KeyValueArgs, - code: poise::CodeBlock, -) -> Result<(), Error> { - ctx.say(format!("Key value args: {:?}\nCode: {}", args, code)) - .await?; - Ok(()) -} - -#[poise::command(prefix_command, slash_command)] -pub async fn say( - ctx: Context<'_>, - #[rest] - #[description = "Text to say"] - msg: String, -) -> Result<(), Error> { - ctx.say(msg).await?; - Ok(()) -} - -/// View the difference between two file sizes -#[poise::command(prefix_command, slash_command)] -pub async fn file_details( - ctx: Context<'_>, - #[description = "File to examine"] file: serenity::Attachment, - #[description = "Second file to examine"] file_2: Option, -) -> Result<(), Error> { - ctx.say(format!( - "First file name: **{}**. File size difference: **{}** bytes", - file.filename, - file.size - file_2.map_or(0, |f| f.size) - )) - .await?; - Ok(()) -} - -#[poise::command(prefix_command)] -pub async fn totalsize( - ctx: Context<'_>, - #[description = "File to rename"] files: Vec, -) -> Result<(), Error> { - // we cast to u64 here as (10 files times 500mb each) > u32::MAX (~4.2gb) - let total = files.iter().map(|f| f.size as u64).sum::(); - - ctx.say(format!( - "Total file size: `{}B`. Average size: `{}B`", - total, - total.checked_div(files.len() as _).unwrap_or(0) - )) - .await?; - - Ok(()) -} - -#[derive(Debug, poise::Modal)] -#[allow(dead_code)] // fields only used for Debug print -struct MyModal { - first_input: String, - second_input: Option, -} -#[poise::command(slash_command)] -pub async fn modal( - ctx: poise::ApplicationContext<'_, crate::Data, crate::Error>, -) -> Result<(), Error> { - use poise::Modal as _; - - let data = MyModal::execute(ctx).await?; - println!("Got data: {:?}", data); - - Ok(()) -} - -#[derive(Debug, poise::ChoiceParameter)] -pub enum PunishType { - Ban, - Kick, - Mute, -} - -/// Punishment command for testing the rename macro -#[poise::command(slash_command)] -pub async fn punish( - ctx: Context<'_>, - #[description = "Punishment type"] - #[rename = "type"] - punish_type: PunishType, - #[description = "User to execute the punishment on"] user: serenity::User, -) -> Result<(), Error> { - let text = match punish_type { - PunishType::Ban => format!("{} has been banned!", user.name), - PunishType::Kick => format!("{} has been kicked!", user.name), - PunishType::Mute => format!("{} has been muted!", user.name), - }; - ctx.say(text).await?; - - Ok(()) -} - -#[cfg(feature = "cache")] -#[poise::command(slash_command, prefix_command)] -pub async fn servers(ctx: Context<'_>) -> Result<(), Error> { - poise::builtins::servers(ctx).await?; - Ok(()) -} - -#[poise::command(slash_command, prefix_command)] -pub async fn reply(ctx: Context<'_>) -> Result<(), Error> { - ctx.send( - poise::CreateReply::new() - .content(format!("Hello {}!", ctx.author().name)) - .reply(true), - ) - .await?; - - Ok(()) -} diff --git a/examples/framework_usage/commands/mod.rs b/examples/framework_usage/commands/mod.rs deleted file mode 100644 index 4334ee8fc72c..000000000000 --- a/examples/framework_usage/commands/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod autocomplete; -pub mod checks; -pub mod context_menu; -pub mod general; -pub mod localization; -pub mod subcommands; diff --git a/examples/help_generation/main.rs b/examples/help_generation/main.rs new file mode 100644 index 000000000000..633098ea8859 --- /dev/null +++ b/examples/help_generation/main.rs @@ -0,0 +1,359 @@ +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 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() + }; + + serenity::Client::builder( + std::env::var("TOKEN").unwrap(), + serenity::GatewayIntents::non_privileged(), + ) + .framework(poise::Framework::new( + options, + move |ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }, + )) + .await + .unwrap() + .start() + .await + .unwrap(); +} diff --git a/examples/manual_dispatch/main.rs b/examples/manual_dispatch/main.rs index 045a5df80604..0aead9cf8f3b 100644 --- a/examples/manual_dispatch/main.rs +++ b/examples/manual_dispatch/main.rs @@ -24,7 +24,7 @@ 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(std::num::NonZeroU64::new(846453852164587620).unwrap()), + bot_id: 846453852164587620.into(), options: &self.options, user_data: &(), shard_manager: &shard_manager, diff --git a/examples/quickstart/main.rs b/examples/quickstart/main.rs index 6183db1879c9..c8b2b716f5d3 100644 --- a/examples/quickstart/main.rs +++ b/examples/quickstart/main.rs @@ -1,9 +1,8 @@ use poise::serenity_prelude as serenity; +struct Data {} // User data, which is stored and accessible in all command invocations type Error = Box; type Context<'a> = poise::Context<'a, Data, Error>; -// User data, which is stored and accessible in all command invocations -struct Data {} /// Displays your or another user's account creation date #[poise::command(slash_command, prefix_command)] @@ -17,22 +16,21 @@ async fn age( Ok(()) } -#[poise::command(prefix_command)] -async fn register(ctx: Context<'_>) -> Result<(), Error> { - poise::builtins::register_application_commands_buttons(ctx).await?; - Ok(()) -} - #[tokio::main] async fn main() { let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); let intents = serenity::GatewayIntents::non_privileged(); let framework = poise::Framework::new( poise::FrameworkOptions { - commands: vec![age(), register()], + commands: vec![age()], ..Default::default() }, - move |_ctx, _ready, _framework| Box::pin(async move { Ok(Data {}) }), + |ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }, ); let mut client = serenity::Client::builder(token, intents) .framework(framework) diff --git a/examples/serenity_example_port/main.rs b/examples/serenity_example_port/main.rs deleted file mode 100644 index eeec075f5a91..000000000000 --- a/examples/serenity_example_port/main.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! Requires the 'framework' feature flag be enabled in your project's -//! `Cargo.toml`. -//! -//! This can be enabled by specifying the feature in the dependency section: -//! -//! ```toml -//! [dependencies.serenity] -//! git = "https://github.com/serenity-rs/serenity.git" -//! features = ["framework", "standard_framework"] -//! ``` -use poise::serenity_prelude as serenity; -use std::fmt::Write as _; - -/// A shared instance of this struct is available across all events and framework commands -struct Data { - command_counter: std::sync::Mutex>, -} -/// This Error type is used throughout all commands and callbacks -type Error = Box; - -/// This type alias will save us some typing, because the Context type is needed often -type Context<'a> = poise::Context<'a, Data, Error>; - -async fn event_listener( - event: &serenity::FullEvent, - _framework: poise::FrameworkContext<'_, Data, Error>, - _user_data: &Data, -) -> Result<(), Error> { - match event { - serenity::FullEvent::Ready { data_about_bot, .. } => { - println!("{} is connected!", data_about_bot.user.name) - } - _ => {} - } - - Ok(()) -} - -// INFO: poise doesn't yet support sophisticated groups like this -/* -// Sets multiple prefixes for a group. -// This requires us to call commands in this group -// via `~emoji` (or `~em`) instead of just `~`. -#[prefixes("emoji", "em")] -// Set a description to appear if a user wants to display a single group -// e.g. via help using the group-name or one of its prefixes. -#[description = "A group with commands providing an emoji as response."] -// Summary only appears when listing multiple groups. -#[summary = "Do emoji fun!"] -// Sets a command that will be executed if only a group-prefix was passed. -#[default_command(bird)] -#[commands(cat, dog)] -struct Emoji; - -#[group] -// Sets a single prefix for this group. -// So one has to call commands in this group -// via `~math` instead of just `~`. -#[prefix = "math"] -#[commands(multiply)] -struct Math; - -#[group] -#[owners_only] -// Limit all commands to be guild-restricted. -#[only_in(guilds)] -// Summary only appears when listing multiple groups. -#[summary = "Commands for server owners"] -#[commands(slow_mode)] -struct Owner; -*/ - -// INFO: this level of customization is currently not supported by poise's built-in help feature -/* -// Some arguments require a `{}` in order to replace it with contextual information. -// In this case our `{}` refers to a command's name. -#[command_not_found_text = "Could not find: `{}`."] -// Define the maximum Levenshtein-distance between a searched command-name -// and commands. If the distance is lower than or equal the set distance, -// it will be displayed as a suggestion. -// Setting the distance to 0 will disable suggestions. -#[max_levenshtein_distance(3)] -// When you use sub-groups, Serenity will use the `indention_prefix` to indicate -// how deeply an item is indented. -// The default value is "-", it will be changed to "+". -#[indention_prefix = "+"] -// On another note, you can set up the help-menu-filter-behaviour. -// Here are all possible settings shown on all possible options. -// First case is if a user lacks permissions for a command, we can hide the command. -#[lacking_permissions = "Hide"] -// If the user is nothing but lacking a certain role, we just display it hence our variant is `Nothing`. -#[lacking_role = "Nothing"] -// The last `enum`-variant is `Strike`, which ~~strikes~~ a command. -#[wrong_channel = "Strike"] -// Serenity will automatically analyse and generate a hint/tip explaining the possible -// cases of ~~strikethrough-commands~~, but only if -// `strikethrough_commands_tip_in_{dm, guild}` aren't specified. -// If you pass in a value, it will be displayed instead. -*/ -// The framework provides built-in help functionality for you to use. -// You just have to set the metadata of the command like descriptions, to fit with the rest of your -// bot. The actual help text generation is delegated to poise -/// Show a help menu -#[poise::command(prefix_command, slash_command)] -async fn help( - ctx: Context<'_>, - #[description = "Command to display specific information about"] command: Option, -) -> Result<(), Error> { - let config = poise::builtins::HelpConfiguration { - extra_text_at_bottom: "\ -Hello! こんにちは!Hola! Bonjour! 您好! 안녕하세요~ - -If you want more information about a specific command, just pass the command as argument.", - ..Default::default() - }; - - poise::builtins::help(ctx, command.as_deref(), config).await?; - - Ok(()) -} - -/// Registers slash commands in this guild or globally -#[poise::command(prefix_command, hide_in_help)] -async fn register(ctx: Context<'_>) -> Result<(), Error> { - poise::builtins::register_application_commands_buttons(ctx).await?; - - Ok(()) -} - -async fn pre_command(ctx: Context<'_>) { - println!( - "Got command '{}' by user '{}'", - ctx.command().name, - ctx.author().name - ); - - // Increment the number of times this command has been run once. If - // the command's name does not exist in the counter, add a default - // value of 0. - let mut command_counter = ctx.data().command_counter.lock().unwrap(); - let entry = command_counter - .entry(ctx.command().name.to_string()) - .or_insert(0); - *entry += 1; -} - -async fn post_command(ctx: Context<'_>) { - println!("Processed command '{}'", ctx.command().name); -} - -// TODO: unify the command checks in poise::FrameworkOptions and then implement a command check here -// with this in it: -// ``` -// true // if `check` returns false, command processing doesn't happen. -// ``` - -async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { - match error { - poise::FrameworkError::Command { error, ctx } => { - println!( - "Command '{}' returned error {:?}", - ctx.command().name, - error - ); - } - poise::FrameworkError::Listener { error, event, .. } => { - println!( - "Listener returned error during {:?} event: {:?}", - event.snake_case_name(), - error - ); - } - error => { - if let Err(e) = poise::builtins::on_error(error).await { - println!("Error while handling error: {}", e) - } - } - } -} - -// INFO: Poise currently does not support callbacks for these events -/*#[hook] -async fn unknown_command(_ctx: &Context, _msg: &Message, unknown_command_name: &str) { - println!("Could not find command named '{}'", unknown_command_name); -} - -#[hook] -async fn normal_message(_ctx: Context<'_>) { - println!("Message is not a command '{}'", msg.content); -}*/ - -// INFO: Currently not applicable because poise doesn't have cooldowns -/*#[hook] -async fn delay_action(ctx: Context<'_>) { - // You may want to handle a Discord rate limit if this fails. - let _ = msg.react(ctx, '⏱').await; -} - -#[hook] -async fn dispatch_error(ctx: Context<'_>, error: DispatchError) { - if let DispatchError::Ratelimited(info) = error { - // We notify them only once. - if info.is_first_try { - let _ = msg - .channel_id - .say(&ctx.http, &format!("Try this again in {} seconds.", info.as_secs())) - .await; - } - } -} - -// You can construct a hook without the use of a macro, too. -// This requires some boilerplate though and the following additional import. -use serenity::{futures::future::BoxFuture, FutureExt}; -fn _dispatch_error_no_macro<'fut>( - ctx: &'fut mut Context, - msg: &'fut Message, - error: DispatchError, -) -> BoxFuture<'fut, ()> { - async move { - if let DispatchError::Ratelimited(info) = error { - if info.is_first_try { - let _ = msg - .channel_id - .say(&ctx.http, &format!("Try this again in {} seconds.", info.as_secs())) - .await; - } - }; - } - .boxed() -}*/ - -#[tokio::main] -async fn main() { - let options = poise::FrameworkOptions { - commands: vec![ - // The `#[poise::command(prefix_command, slash_command)]` macro transforms the function into - // `fn() -> poise::Command`. - // Therefore, you need to call the command function without any arguments to get the - // command definition instance to pass to the framework - help(), - // This function registers slash commands on Discord. When you change something about a - // command signature, for example by changing its name, adding or removing parameters, or - // changing a parameter type, you should call this function. - register(), - about(), - am_i_admin(), - say(), - commands(), - ping(), - latency(), - some_long_command(), - upper_command(), - bird(), - cat(), - dog(), - multiply(), - slow_mode(), - ], - listener: |event, framework, user_data| { - Box::pin(event_listener(event, framework, user_data)) - }, - on_error: |error| Box::pin(on_error(error)), - // Set a function to be called prior to each command execution. This - // provides all context of the command that would also be passed to the actual command code - pre_command: |ctx| Box::pin(pre_command(ctx)), - // Similar to `pre_command`, except will be called directly _after_ - // command execution. - post_command: |ctx| Box::pin(post_command(ctx)), - - // Options specific to prefix commands, i.e. commands invoked via chat messages - prefix_options: poise::PrefixFrameworkOptions { - prefix: Some(String::from("~")), - - mention_as_prefix: false, - // An edit tracker needs to be supplied here to make edit tracking in commands work - edit_tracker: Some(poise::EditTracker::for_timespan( - std::time::Duration::from_secs(3600 * 3), - )), - ..Default::default() - }, - - ..Default::default() - }; - - serenity::Client::builder( - // Configure the client with your Discord bot token in the environment. - std::env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in the environment"), - serenity::GatewayIntents::non_privileged(), - ) - // The Framework will automatically retrieve the bot owner and application ID via the - // passed token, so that information need not be passed here - .framework(poise::Framework::new( - options, - |_ctx, _data_about_bot, _framework| { - Box::pin(async move { - Ok(Data { - command_counter: std::sync::Mutex::new(std::collections::HashMap::new()), - }) - }) - }, - )) - .await - .expect("build client") - .start() - .await - .expect("Client error"); - - // INFO: currently not supported by poise - /* - // Set a function that's called whenever an attempted command-call's - // command could not be found. - .unrecognised_command(unknown_command) - // Set a function that's called whenever a message is not a command. - .normal_message(normal_message) - // Set a function that's called whenever a command's execution didn't complete for one - // reason or another. For example, when a user has exceeded a rate-limit or a command - // can only be performed by the bot owner. - .on_dispatch_error(dispatch_error) - // Can't be used more than once per 5 seconds: - .bucket("emoji", |b| b.delay(5)).await - // Can't be used more than 2 times per 30 seconds, with a 5 second delay applying per channel. - // Optionally `await_ratelimits` will delay until the command can be executed instead of - // cancelling the command invocation. - .bucket("complicated", |b| b.limit(2).time_span(30).delay(5) - // The target each bucket will apply to. - .limit_for(LimitedFor::Channel) - // The maximum amount of command invocations that can be delayed per target. - // Setting this to 0 (default) will never await/delay commands and cancel the invocation. - .await_ratelimits(1) - // A function to call when a rate limit leads to a delay. - .delay_action(delay_action) - ).await - */ -} - -// Commands can be created via the attribute `#[poise::command()]` macro. -// Options are passed as arguments to the macro. -#[poise::command(prefix_command, slash_command, category = "General")] -// INFO: not supported -/* -// Make this command use the "complicated" bucket. -#[bucket = "complicated"] -*/ -/// Shows how often each command was used -async fn commands(ctx: Context<'_>) -> Result<(), Error> { - let mut contents = "Commands used:\n".to_string(); - - for (k, v) in &*ctx.data().command_counter.lock().unwrap() { - writeln!(contents, "- {name}: {amount}", name = k, amount = v)?; - } - - ctx.say(contents).await?; - - Ok(()) -} - -/// Repeats what the user passed as argument safely -/// -/// Ensures that user and role mentions are replaced with a safe textual alternative. -// In this example channel mentions are excluded via the `ContentSafeOptions`. -// The track_edits argument ensures that when the user edits their command invocation, -// the bot updates the response message accordingly. -#[poise::command(prefix_command, slash_command, track_edits, category = "General")] -async fn say( - ctx: Context<'_>, - #[description = "Text to repeat"] - #[rest] - content: String, -) -> Result<(), Error> { - let settings = if let Some(guild_id) = ctx.guild_id() { - // By default roles, users, and channel mentions are cleaned. - serenity::ContentSafeOptions::default() - // We do not want to clean channal mentions as they - // do not ping users. - .clean_channel(false) - // If it's a guild channel, we want mentioned users to be displayed - // as their display name. - .display_as_member_from(guild_id) - } else { - serenity::ContentSafeOptions::default() - .clean_channel(false) - .clean_role(false) - }; - - let content = serenity::content_safe(ctx.discord(), &content, &settings, { - // If we are in a prefix command, we pass the Users that were mentioned in the message - // to avoid them needing to be fetched from cache - if let poise::Context::Prefix(ctx) = ctx { - &ctx.msg.mentions - } else { - &[] - } - }); - - ctx.say(content).await?; - - Ok(()) -} - -// A function which acts as a "check", to determine whether to call a command. -// -// In this case, this command checks to ensure you are the owner of the message -// in order for the command to be executed. If the check fails, the command is -// not called. -// -// Note: to allow command execution only for the owner, the owners_only #[command] macro argument -// is a much better method than this. This check is used just as an example. -async fn owner_check(ctx: Context<'_>) -> Result { - // Replace 7 with your ID to make this check pass. - if ctx.author().id != 7 { - return Ok(false); - } - - Ok(true) -} - -/// This is a command with a deliberately long name -#[poise::command(prefix_command, slash_command, category = "General")] -async fn some_long_command( - ctx: Context<'_>, - #[description = "Arguments to this command"] - #[rest] - args: String, -) -> Result<(), Error> { - ctx.say(format!("Arguments: {:?}", args)).await?; - - Ok(()) -} - -/// Retrieves the role ID of a role -#[poise::command(prefix_command, slash_command)] -// INFO: not implemented -/* -// Limits the usage of this command to roles named: -#[allowed_roles("mods", "ultimate neko")] -*/ -async fn about_role( - ctx: Context<'_>, - #[description = "Name of the role"] potential_role_name: String, -) -> Result<(), Error> { - let role_id = ctx - .guild() - // `role_by_name()` allows us to attempt attaining a reference to a role - // via its name. - .and_then(|guild| guild.role_by_name(&potential_role_name).map(|role| role.id)); - if let Some(role_id) = role_id { - if let Err(why) = ctx.say(format!("Role-ID: {}", role_id)).await { - println!("Error sending message: {:?}", why); - } - - return Ok(()); - } - - poise::say_reply( - ctx, - format!("Could not find role named: {:?}", potential_role_name), - ) - .await?; - - Ok(()) -} - -/// Multiplies two numbers -#[poise::command( - prefix_command, - slash_command, - // Lets us also call `~math *` instead of just `~math multiply`. - aliases("*"), - category = "Math", -)] -async fn multiply( - ctx: Context<'_>, - #[description = "First number"] first: f64, - #[description = "Second number"] second: f64, -) -> Result<(), Error> { - let res = first * second; - - ctx.say(res.to_string()).await?; - - Ok(()) -} - -/// Shows information about this bot -#[poise::command(prefix_command, slash_command, category = "General")] -async fn about(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("This is a small test-bot! : )").await?; - - Ok(()) -} - -/// Shows current latency of this bot -#[poise::command(prefix_command, slash_command, category = "General")] -async fn latency(ctx: Context<'_>) -> Result<(), Error> { - // The shard manager is an interface for mutating, stopping, restarting, and - // retrieving information about shards. - let shard_manager = ctx.framework().shard_manager(); - - let runners = shard_manager.runners.lock().await; - - // Shards are backed by a "shard runner" responsible for processing events - // over the shard, so we'll get the information about the shard runner for - // the shard this command was sent over. - let runner = runners - .get(&ctx.discord().shard_id) - .ok_or("No shard found")?; - - ctx.say(format!("The shard latency is {:?}", runner.latency)) - .await?; - - Ok(()) -} - -#[poise::command( - prefix_command, - slash_command, - check = "owner_check", - category = "General" -)] -// INFO: not implemented -/* -// Limit command usage to guilds. -#[only_in(guilds)] -*/ -/// Ping pong -async fn ping(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("Pong! : )").await?; - - Ok(()) -} - -/// Sends an emoji with a cat. -#[poise::command( - prefix_command, - slash_command, - // Adds multiple aliases - aliases("kitty", "neko"), - // Allow only administrators to call this: - required_permissions = "ADMINISTRATOR", - category = "Emoji" -)] -// INFO: not implemented -/* -// Make this command use the "emoji" bucket. -#[bucket = "emoji"] -*/ -async fn cat(ctx: Context<'_>) -> Result<(), Error> { - ctx.say(":cat:").await?; - - // INFO: buckets not implemented - /* - // We can return one ticket to the bucket undoing the ratelimit. - Err(RevertBucket.into()) - */ - - Ok(()) -} - -/// Sends an emoji with a dog. -#[poise::command(prefix_command, slash_command, category = "Emoji")] -// INFO: not implemented -/* -#[bucket = "emoji"] -*/ -async fn dog(ctx: Context<'_>) -> Result<(), Error> { - ctx.say(":dog:").await?; - - Ok(()) -} - -/// Sends an emoji with a bird. -#[poise::command(prefix_command, slash_command, category = "Emoji")] -async fn bird( - ctx: Context<'_>, - #[description = "Name of the bird you're searching for"] bird_name: Option, -) -> Result<(), Error> { - let say_content = match bird_name { - None => ":bird: can find animals for you.".to_string(), - Some(bird_name) => format!(":bird: could not find animal named: `{}`.", bird_name), - }; - - ctx.say(say_content).await?; - - Ok(()) -} - -// We could also use `required_permissions = "ADMINISTRATOR"` -// but that would not let us reply when it fails. -/// Tells you whether you are an admin on the server -#[poise::command(prefix_command, slash_command, category = "General")] -async fn am_i_admin(ctx: Context<'_>) -> Result<(), Error> { - if let Some(guild_id) = ctx.guild_id() { - for role in guild_id.member(ctx.discord(), ctx.author().id).await?.roles { - if role.to_role_cached(ctx.discord()).map_or(false, |r| { - r.has_permission(serenity::Permissions::ADMINISTRATOR) - }) { - ctx.say("Yes, you are.").await?; - - return Ok(()); - } - } - } - - ctx.say("No, you are not.").await?; - - Ok(()) -} - -/// Enable slowmode for a channel. Pass no argument to disable slowmode -#[poise::command( - prefix_command, - slash_command, - check = "owner_check", - category = "Owner" -)] -async fn slow_mode( - ctx: Context<'_>, - #[description = "Minimum time between sending messages per user"] rate_limit: Option, -) -> Result<(), Error> { - let say_content = if let Some(rate_limit) = rate_limit { - if let Err(why) = ctx - .channel_id() - .edit( - ctx.discord(), - serenity::EditChannel::default().rate_limit_per_user(rate_limit), - ) - .await - { - println!("Error setting channel's slow mode rate: {:?}", why); - format!("Failed to set slow mode to `{}` seconds.", rate_limit) - } else { - format!( - "Successfully set slow mode rate to `{}` seconds.", - rate_limit - ) - } - } else if let Some(serenity::Channel::Guild(channel)) = - ctx.channel_id().to_channel_cached(ctx.discord()) - { - format!( - "Current slow mode rate is `{}` seconds.", - channel.rate_limit_per_user.unwrap_or(0) - ) - } else { - "Failed to find channel in cache.".to_string() - }; - - ctx.say(say_content).await?; - - Ok(()) -} - -/// Dummy command to test subcommands -#[poise::command( - prefix_command, - slash_command, - rename = "upper", - category = "General", - // A command can have sub-commands, just like in command lines tools. - // Imagine `cargo help` and `cargo help run`. - subcommands("sub") -)] -async fn upper_command(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("This is the main function!").await?; - - Ok(()) -} - -/// "This is `upper`'s sub-command. -#[poise::command(prefix_command, slash_command, aliases("sub-command", "secret"))] -// This will only be called if preceded by the `upper`-command. -async fn sub(ctx: Context<'_>) -> Result<(), Error> { - ctx.say("This is a sub function!").await?; - - Ok(()) -} diff --git a/examples/testing/main.rs b/examples/testing/main.rs deleted file mode 100644 index eaec9aeace3c..000000000000 --- a/examples/testing/main.rs +++ /dev/null @@ -1,87 +0,0 @@ -use poise::serenity_prelude as serenity; - -type Error = Box; -type Context<'a> = poise::Context<'a, Data, Error>; -// User data, which is stored and accessible in all command invocations -struct Data {} - -async fn child2_check(_ctx: Context<'_>) -> Result { - println!("Child2 check executed!"); - Ok(true) -} -async fn child1_check(_ctx: Context<'_>) -> Result { - println!("Child1 check executed!"); - Ok(true) -} -async fn parent_check(_ctx: Context<'_>) -> Result { - println!("Parent check executed!"); - Ok(true) -} - -#[poise::command(slash_command, prefix_command, check = "child2_check")] -async fn child2( - ctx: Context<'_>, - _b: bool, - _s: String, - _i: u32, - _a: Option, - _c: serenity::Channel, - _r: serenity::Role, - _u: serenity::User, -) -> Result<(), Error> { - ctx.say(ctx.invocation_string()).await?; - Ok(()) -} -#[poise::command( - slash_command, - prefix_command, - subcommands("child2"), - check = "child1_check" -)] -async fn child1(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} -#[poise::command( - slash_command, - prefix_command, - subcommands("child1"), - check = "parent_check" -)] -async fn parent(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -#[tokio::main] -async fn main() { - let mut client = serenity::Client::builder( - std::env::var("DISCORD_TOKEN").unwrap(), - serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT, - ) - .framework(poise::Framework::new( - poise::FrameworkOptions { - commands: vec![parent()], - prefix_options: poise::PrefixFrameworkOptions { - prefix: Some("~".into()), - ..Default::default() - }, - ..Default::default() - }, - move |ctx, _ready, framework| { - Box::pin(async move { - let guild_id = - serenity::GuildId(std::env::var("GUILD_ID").unwrap().parse().unwrap()); - guild_id - .set_commands( - ctx, - poise::builtins::create_application_commands(&framework.options().commands), - ) - .await?; - Ok(Data {}) - }) - }, - )) - .await - .unwrap(); - - client.start().await.unwrap(); -} diff --git a/macros/Cargo.toml b/macros/Cargo.toml index c39425084b25..bd8252f31ddd 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "poise_macros" -version = "0.4.0" # remember to update the version -authors = ["kangalioo "] +version = "0.5.5" # remember to update the version +authors = ["kangalio "] edition = "2018" description = "Internal macro implementation crate of poise" license = "MIT" -repository = "https://github.com/kangalioo/poise/" +repository = "https://github.com/serenity-rs/poise/" [lib] proc-macro = true diff --git a/macros/src/choice_parameter.rs b/macros/src/choice_parameter.rs index 518f63befaa6..042545854fae 100644 --- a/macros/src/choice_parameter.rs +++ b/macros/src/choice_parameter.rs @@ -64,74 +64,42 @@ pub fn choice_parameter(input: syn::DeriveInput) -> Result, - value: &poise::serenity_prelude::ResolvedValue<'_>, - ) -> ::std::result::Result { - let choice_key = match value { - poise::serenity_prelude::ResolvedValue::Integer(i) => i, - _ => return Err(poise::SlashArgError::CommandStructureMismatch( - "expected integer", - )), - }; - - match choice_key { - #( #indices => Ok(Self::#variant_idents), )* - _ => Err(poise::SlashArgError::CommandStructureMismatch("out of bounds choice key")), - } - } - - fn create(builder: poise::serenity_prelude::CreateCommandOption) -> poise::serenity_prelude::CreateCommandOption { - builder.kind(poise::serenity_prelude::CommandOptionType::Integer) - } - - fn choices() -> Vec { + impl poise::ChoiceParameter for #enum_ident { + fn list() -> Vec { vec![ #( poise::CommandParameterChoice { name: #names.to_string(), localizations: std::collections::HashMap::from([ - #( (#locales.to_string(), #localized_names.to_string()) )* + #( (#locales.to_string(), #localized_names.to_string()) ),* ]), + __non_exhaustive: (), }, )* ] } - } - - impl std::str::FromStr for #enum_ident { - type Err = poise::InvalidChoice; - fn from_str(s: &str) -> ::std::result::Result { - #( - if s.eq_ignore_ascii_case(#names) - #( || s.eq_ignore_ascii_case(#alternative_names) )* - { - Ok(Self::#variant_idents) - } else - )* { - Err(poise::InvalidChoice) + fn from_index(index: usize) -> Option { + match index { + #( #indices => Some(Self::#variant_idents), )* + _ => None, } } - } - impl std::fmt::Display for #enum_ident { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.name()) + fn from_name(name: &str) -> Option { + #( if name.eq_ignore_ascii_case(#names) + #( || name.eq_ignore_ascii_case(#alternative_names) )* + { + return Some(Self::#variant_idents); + } )* + None } - } - impl #enum_ident { - /// Returns the non-localized name of this choice - pub fn name(&self) -> &'static str { + fn name(&self) -> &'static str { match self { #( Self::#variant_idents => #names, )* } } - /// Returns the localized name for the given locale, if one is set - pub fn localized_name(&self, locale: &str) -> Option<&'static str> { + fn localized_name(&self, locale: &str) -> Option<&'static str> { match self { #( Self::#variant_idents => match locale { #( #locales => Some(#localized_names), )* diff --git a/macros/src/command/mod.rs b/macros/src/command/mod.rs index a5cfcf4da4f6..9d6655f66325 100644 --- a/macros/src/command/mod.rs +++ b/macros/src/command/mod.rs @@ -1,7 +1,7 @@ mod prefix; mod slash; -use crate::util::wrap_option; +use crate::util::{wrap_option, wrap_option_to_string}; use proc_macro::TokenStream; use syn::spanned::Spanned as _; @@ -18,8 +18,10 @@ pub struct CommandArgs { // if it's actually irrational, the inconsistency should be fixed) subcommands: crate::util::List, aliases: crate::util::List, + subcommand_required: bool, invoke_on_edit: bool, reuse_response: bool, + track_deletion: bool, track_edits: bool, broadcast_typing: bool, help_text_fn: Option, @@ -68,6 +70,8 @@ struct ParamArgs { channel_types: Option>, min: Option, max: Option, + min_length: Option, + max_length: Option, lazy: bool, flag: bool, rest: bool, @@ -75,7 +79,7 @@ struct ParamArgs { /// Part of the Invocation struct. Represents a single parameter of a Discord command. struct CommandParameter { - name: syn::Ident, + name: Option, type_: syn::Type, args: ParamArgs, span: proc_macro2::Span, @@ -143,6 +147,18 @@ pub fn command( return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); } + // If subcommand_required is set to true, then the command cannot have any arguments + if args.subcommand_required && function.sig.inputs.len() > 1 { + let err_msg = "subcommand_required is set to true, but the command has arguments"; + return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); + } + + // If subcommand_required is set to true, then the command must have at least one subcommand + if args.subcommand_required && args.subcommands.0.is_empty() { + let err_msg = "subcommand_required is set to true, but the command has no subcommands"; + return Err(syn::Error::new(proc_macro2::Span::call_site(), err_msg).into()); + } + // Collect argument names/types/attributes to insert into generated function let mut parameters = Vec::new(); for command_param in function.sig.inputs.iter_mut().skip(1) { @@ -153,10 +169,8 @@ pub fn command( } }; let name = match &*pattern.pat { - syn::Pat::Ident(pat_ident) => &pat_ident.ident, - x => { - return Err(syn::Error::new(x.span(), "must use an identifier pattern here").into()) - } + syn::Pat::Ident(pat_ident) => Some(pat_ident.ident.clone()), + _ => None, }; let attrs = pattern @@ -167,7 +181,7 @@ pub fn command( let attrs = ::from_list(&attrs)?; parameters.push(CommandParameter { - name: name.clone(), + name, type_: (*pattern.ty).clone(), args: attrs, span: command_param.span(), @@ -242,14 +256,14 @@ fn generate_command(mut inv: Invocation) -> Result quote::quote! { Some(#x.to_string()) }, None => quote::quote! { None }, }; let hide_in_help = &inv.args.hide_in_help; - let category = wrap_option(inv.args.category.as_ref()); + let category = wrap_option_to_string(inv.args.category.as_ref()); let global_cooldown = wrap_option(inv.args.global_cooldown); let user_cooldown = wrap_option(inv.args.user_cooldown); @@ -260,15 +274,16 @@ fn generate_command(mut inv: Invocation) -> Result quote::quote! { Some(#help_text_fn) }, + Some(help_text_fn) => quote::quote! { Some(#help_text_fn()) }, None => match &inv.help_text { - Some(extracted_explanation) => quote::quote! { Some(|| #extracted_explanation.into()) }, + Some(extracted_explanation) => quote::quote! { Some(#extracted_explanation.into()) }, None => quote::quote! { None }, }, }; @@ -282,6 +297,7 @@ fn generate_command(mut inv: Invocation) -> Result Result Result Result Result proc_macro2::Literal::string(&name.to_string()), + _ => { + return Err(syn::Error::new(p.span, "#[flag] requires a parameter name").into()) + } + }; quote::quote! { #[flag] (#literal) } } Modifier::Lazy => quote::quote! { #[lazy] (#type_) }, @@ -37,7 +42,19 @@ fn quote_parameter(p: &super::CommandParameter) -> Result Result { - let param_names = inv.parameters.iter().map(|p| &p.name).collect::>(); + let param_names: Vec = inv + .parameters + .iter() + .enumerate() + .map(|(i, p)| match &p.name { + Some(x) => x.clone(), + // Generate a synthetic variable name for command parameters without a name + None => syn::Ident::new( + &format!("non_ident_param_{}", i), + proc_macro2::Span::mixed_site(), + ), + }) + .collect::>(); let param_specs = inv .parameters .iter() @@ -50,27 +67,26 @@ pub fn generate_prefix_action(inv: &Invocation) -> Result + ctx.serenity_context, ctx.msg, ctx.args, 0 => #( #param_specs, )* #wildcard_arg - ).await.map_err(|(error, input)| poise::FrameworkError::ArgumentParse { - error, + ).await.map_err(|(error, input)| poise::FrameworkError::new_argument_parse( + ctx.into(), input, - ctx: ctx.into(), - })?; + error, + ))?; if !ctx.framework.options.manual_cooldowns { - ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.into()); + ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.cooldown_context()); } inner(ctx.into(), #( #param_names, )* ) .await - .map_err(|error| poise::FrameworkError::Command { + .map_err(|error| poise::FrameworkError::new_command( + ctx.into(), error, - ctx: ctx.into(), - }) + )) }) }) } diff --git a/macros/src/command/slash.rs b/macros/src/command/slash.rs index c1c082ab77f8..e4ad6d2fd5e3 100644 --- a/macros/src/command/slash.rs +++ b/macros/src/command/slash.rs @@ -1,5 +1,5 @@ use super::Invocation; -use crate::util::extract_type_parameter; +use crate::util::{extract_type_parameter, wrap_option_to_string}; use syn::spanned::Spanned as _; pub fn generate_parameters(inv: &Invocation) -> Result, syn::Error> { @@ -25,8 +25,8 @@ pub fn generate_parameters(inv: &Invocation) -> Result rename.clone(), - None => param.name.to_string(), + Some(rename) => wrap_option_to_string(Some(rename)), + None => wrap_option_to_string(param.name.as_ref().map(|x| x.to_string())), }; let name_locales = param.args.name_localized.iter().map(|x| &x.0); let name_localized_values = param.args.name_localized.iter().map(|x| &x.1); @@ -69,14 +69,25 @@ pub fn generate_parameters(inv: &Invocation) -> Result 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 type_setter = match inv.args.slash_command { true => quote::quote! { Some(|o| { poise::create_slash_argument!(#type_, o) #min_value_setter #max_value_setter + #min_length_setter #max_length_setter }) }, false => quote::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 => quote::quote! { poise::slash_argument_choices!(#type_) }, false => quote::quote! { vec![] }, @@ -92,19 +103,20 @@ pub fn generate_parameters(inv: &Invocation) -> Result Result>(); - let param_names = inv - .parameters - .iter() - .map(|p| match &p.args.rename { - Some(rename) => syn::Ident::new(rename, p.name.span()), - None => p.name.clone(), - }) - .collect::>(); + let mut param_identifiers: Vec = Vec::new(); + let mut param_names: Vec = Vec::new(); + let mut param_types: Vec = Vec::new(); + for p in &inv.parameters { + let param_ident = p.name.clone().ok_or_else(|| { + syn::Error::new(p.span, "parameter must have a name in slash commands") + })?; - let param_types = inv - .parameters - .iter() - .map(|p| match p.args.flag { + param_identifiers.push(param_ident.clone()); + param_names.push(match &p.args.rename { + Some(rename) => syn::Ident::new(rename, p.name.span()), + None => param_ident, + }); + param_types.push(match p.args.flag { true => syn::parse_quote! { FLAG }, false => p.type_.clone(), - }) - .collect::>(); + }); + } Ok(quote::quote! { |ctx| Box::pin(async move { @@ -156,22 +168,21 @@ pub fn generate_slash_action(inv: &Invocation) -> Result + ctx.serenity_context, ctx.interaction, ctx.args => #( (#param_names: #param_types), )* ).await.map_err(|error| error.to_framework_error(ctx))?; if !ctx.framework.options.manual_cooldowns { - ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.into()); + ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.cooldown_context()); } inner(ctx.into(), #( #param_identifiers, )*) .await - .map_err(|error| poise::FrameworkError::Command { + .map_err(|error| poise::FrameworkError::new_command( + ctx.into(), error, - ctx: ctx.into(), - }) + )) }) }) } @@ -193,15 +204,15 @@ pub fn generate_context_menu_action( <#param_type as ::poise::ContextMenuParameter<_, _>>::to_action(|ctx, value| { Box::pin(async move { if !ctx.framework.options.manual_cooldowns { - ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.into()); + ctx.command.cooldowns.lock().unwrap().start_cooldown(ctx.cooldown_context()); } inner(ctx.into(), value) .await - .map_err(|error| poise::FrameworkError::Command { + .map_err(|error| poise::FrameworkError::new_command( + ctx.into(), error, - ctx: ctx.into(), - }) + )) }) }) }) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 592d3b872ec3..01e1595fddec 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -19,42 +19,69 @@ for example for command-specific help (i.e. `~help command_name`). Escape newlin # Macro arguments `#[poise::command]` accepts a number of arguments to configure the command: + +## Command types + - `prefix_command`: Generate a prefix command - `slash_command`: Generate a slash command - `context_menu_command`: Generate a context menu command -- `description_localized`: Adds localized description of the parameter `description_localized("locale", "Description")` (slash-only) -- `name_localized`: Adds localized name of the parameter `name_localized("locale", "new_name")` (slash-only) + +## Meta properties + - `subcommands`: List of subcommands `subcommands("foo", "bar", "baz")` -- `aliases`: Command name aliases (only applies to prefix commands) -- `invoke_on_edit`: Reruns the command if an existing invocation message is edited (prefix only) -- `reuse_response`: After the first response, post subsequent responses as edits to the initial message (prefix only) -- `track_edits`: Shorthand for `invoke_on_edit` and `reuse_response` (prefix only) -- `broadcast_typing`: Trigger a typing indicator while command runs (only applies to prefix commands I think) -- `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 -- `check`: Path to a function which is invoked for every invocation. If the function returns false, the command is not executed (can be used multiple times) -- `on_error`: Error handling function +- `name_localized`: Adds localized name of the parameter `name_localized("locale", "new_name")` (slash-only) +- `description_localized`: Adds localized description of the parameter `description_localized("locale", "Description")` (slash-only) - `rename`: Choose an alternative command name instead of the function name - Useful if your command name is a Rust keyword, like `move` -- `discard_spare_arguments`: Don't throw an error if the user supplies too many arguments -- `hide_in_help`: Hide this command in help menus -- `ephemeral`: Make bot responses ephemeral if possible - - Only poise's function, like `poise::send_reply`, respect this preference +- `aliases`: Command name aliases (only applies to prefix commands) +- `category`: Category of this command which affects placement in the help command +- `custom_data`: Arbitrary expression that will be boxed and stored in `Command::custom_data` +- `identifying_name`: Optionally, a unique identifier for this command for your personal usage + +## Checks + - `required_permissions`: Permissions which the command caller needs to have - `required_bot_permissions`: Permissions which the bot is known to need +- `default_member_permissions`: Like `required_permissions`, but checked server-side (slash only) + - Due to being checked server-side, users without the required permissions are prevented from executing the command in the first place, which is a better experience + - However, `default_member_permissions` has no effect on subcommands, which always inherit their permissions from the top-level command + - Also, guild owners can freely change the required permissions for any bot command for their guild - `owners_only`: Restricts command callers to a configurable list of owners (see FrameworkOptions) - `guild_only`: Restricts command callers to only run on a guild - `dm_only`: Restricts command callers to only run on a DM - `nsfw_only`: Restricts command callers to only run on a NSFW channel -- `identifying_name`: Optionally, a unique identifier for this command for your personal usage -- `category`: Category of this command which affects placement in the help command -- `custom_data`: Arbitrary expression that will be boxed and stored in `Command::custom_data` +- `subcommand_required`: Requires a subcommand to be specified (prefix only) +- `check`: Path to a function which is invoked for every invocation. If the function returns false, the command is not executed (can be used multiple times) + +## 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) + +- `track_edits`: Shorthand for `invoke_on_edit`, `track_deletion`, and `reuse_response` (prefix only) +- `invoke_on_edit`: Reruns the command if an existing invocation message is edited (prefix only) +- `track_deletion`: Deletes the bot response to a command if the command message is deleted (prefix only) +- `reuse_response`: After the first response, post subsequent responses as edits to the initial message (prefix only) + +## Cooldown + - `global_cooldown`: Minimum duration in seconds between invocations, globally - `user_cooldown`: Minimum duration in seconds between invocations, per user - `guild_cooldown`: Minimum duration in seconds between invocations, per guild - `channel_cooldown`: Minimum duration in seconds between invocations, per channel - `member_cooldown`: Minimum duration in seconds between invocations, per guild member +## Other + +- `on_error`: Error handling function +- `broadcast_typing`: Trigger a typing indicator while command runs (prefix only) +- `discard_spare_arguments`: Don't throw an error if the user supplies too many arguments (prefix only) +- `ephemeral`: Make bot responses ephemeral if possible (slash only) + - Only poise's function, like `poise::send_reply`, respect this preference + # Function parameters `Context` is the first parameter of all command functions. It's an enum over either PrefixContext or @@ -65,14 +92,24 @@ All following parameters are inputs to the command. You can use all types that i `poise::PopArgumentAsync`, `poise::PopArgument`, `serenity::ArgumentConvert` or `std::str::FromStr`. You can also wrap types in `Option` or `Vec` to make them optional or variadic. In addition, there are multiple attributes you can use on parameters: + +## Meta properties + - `#[description = ""]`: Sets description of the parameter (slash-only) - `#[description_localized("locale", "Description")]`: Adds localized description of the parameter (slash-only) - `#[name_localized("locale", "new_name")]`: Adds localized name of the parameter (slash-only) - `#[autocomplete = "callback()"]`: Sets the autocomplete callback (slash-only) -- `#[channel_types("", "")]`: For channel parameters, restricts allowed channel types (slash-only) - `#[rename = "new_name"]`: Changes the user-facing name of the parameter (slash-only) + +## Input filter (slash only) + +- `#[channel_types("", "")]`: For channel parameters, restricts allowed channel types (slash-only) - `#[min = 0]`: Minimum value for this number parameter (slash-only) - `#[max = 0]`: Maximum value for this number parameter (slash-only) +- `#[min_length = 0]`: Minimum length for this string parameter (slash-only) +- `#[max_length = 1]`: Maximum length for this string parameter (slash-only) + +## Parser settings (prefix only) - `#[rest]`: Use the entire rest of the message for this parameter (prefix-only) - `#[lazy]`: Can be used on Option and Vec parameters and is equivalent to regular expressions' laziness (prefix-only) - `#[flag]`: Can be used on a bool parameter to set the bool to true if the user typed the parameter name literally (prefix-only) diff --git a/macros/src/util.rs b/macros/src/util.rs index 7107031984ad..1babc5c59222 100644 --- a/macros/src/util.rs +++ b/macros/src/util.rs @@ -26,6 +26,14 @@ pub fn wrap_option(literal: Option) -> syn::Expr { } } +/// Converts None => `None` and Some(x) => `Some(#x.to_string())` +pub fn wrap_option_to_string(literal: Option) -> syn::Expr { + match literal { + Some(literal) => syn::parse_quote! { Some(#literal.to_string()) }, + None => syn::parse_quote! { None }, + } +} + /// Syn Fold to make all lifetimes 'static. Used to access trait items of a type without having its /// concrete lifetime available pub struct AllLifetimesToStatic; @@ -75,7 +83,7 @@ pub fn vec_tuple_2_to_hash_map(v: Vec>) -> proc_macro2::TokenStre let (keys, values): (Vec, Vec) = v.into_iter().map(|x| (x.0, x.1)).unzip(); quote::quote! { std::collections::HashMap::from([ - #( (#keys.to_string(), #values.to_string()) )* + #( (#keys.to_string(), #values.to_string()) ),* ]) } } diff --git a/release-guide.md b/release-guide.md index 0a13450081b4..808524f451a0 100644 --- a/release-guide.md +++ b/release-guide.md @@ -14,7 +14,7 @@ Release guide: Behavior changes: - ... - Detailed changelog: https://github.com/kangalioo/poise/compare/v0.2.2...v0.3.0 + Detailed changelog: https://github.com/serenity-rs/poise/compare/v0.2.2...v0.3.0 ``` - Push version bump commit - Add changelog to CHANGELOG.md @@ -23,4 +23,4 @@ Release guide: - Update macros dependency version in /Cargo.toml - Add version tag with `git tag v0.3.0` and `git push origin --tags` - Make GitHub release based on new tag -- Release on crates.io with `cargo publish`, first in /macros, then in root \ No newline at end of file +- Release on crates.io with `cargo publish`, first in /macros, then in root diff --git a/src/builtins/help.rs b/src/builtins/help.rs index 31d30ac6222d..36b4935c9e06 100644 --- a/src/builtins/help.rs +++ b/src/builtins/help.rs @@ -13,6 +13,12 @@ pub struct HelpConfiguration<'a> { 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<'_> { @@ -21,38 +27,201 @@ impl Default for HelpConfiguration<'_> { 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 +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 command = ctx.framework().options().commands.iter().find(|command| { - if command.name.eq_ignore_ascii_case(command_name) { - return true; - } - if let Some(context_menu_name) = command.context_menu_name { + 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 { - match command.help_text { - Some(f) => f(), - None => command - .description - .as_deref() - .unwrap_or("No help available") - .to_owned(), + 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.as_deref().unwrap_or("").to_string(); + 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) }; @@ -61,75 +230,110 @@ async fn help_single_command( Ok(()) } -/// Code for printing an overview of all commands (e.g. `~help`) -async fn help_all_commands( +/// 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<(), serenity::Error> { + config: &HelpConfiguration<'_>, +) -> Result { let mut categories = crate::util::OrderedMap::, Vec<&crate::Command>>::new(); for cmd in &ctx.framework().options().commands { categories - .get_or_insert_with(cmd.category, Vec::new) + .get_or_insert_with(cmd.category.as_deref(), Vec::new) .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 { - menu += category_name.unwrap_or("Commands"); - menu += ":\n"; + 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 { - if command.hide_in_help { - continue; - } - - let prefix = if command.slash_action.is_some() { - String::from("/") - } else if command.prefix_action.is_some() { - let options = &ctx.framework().options().prefix_options; - - match &options.prefix { - Some(fixed_prefix) => 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)) => dynamic_prefix, - // `String::new()` defaults to "" which is what we want - Err(_) | Ok(None) => String::new(), - } - } - None => String::new(), - }, - } - } else { - // This is not a prefix or slash command, i.e. probably a context menu only command - // which we will only show later - continue; - }; - - let total_command_name_length = prefix.chars().count() + command.name.chars().count(); - let padding = 12_usize.saturating_sub(total_command_name_length) + 1; - let _ = writeln!( - menu, - " {}{}{}{}", - prefix, - command.name, - " ".repeat(padding), - command.description.as_deref().unwrap_or("") + 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 kind = match command.context_menu_action { - Some(crate::ContextMenuCommandAction::User(_)) => "user", - Some(crate::ContextMenuCommandAction::Message(_)) => "message", - None => continue, + let name = format_context_menu_name(command); + if name.is_none() { + continue; }; - let name = command.context_menu_name.unwrap_or(&command.name); - let _ = writeln!(menu, " {} (on {})", name, kind); + let _ = writeln!(menu, " {}", name.unwrap()); } } @@ -137,6 +341,15 @@ async fn help_all_commands( 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?; say_ephemeral(ctx, &menu, config.ephemeral).await?; Ok(()) } diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index e6cfc2240b95..4dccdff493de 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -9,6 +9,11 @@ pub use help::*; mod register; pub use register::*; +#[cfg(feature = "chrono")] +mod paginate; +#[cfg(feature = "chrono")] +pub use paginate::*; + use crate::serenity_prelude as serenity; /// Utility function to avoid verbose @@ -47,23 +52,49 @@ pub async fn on_error( ) -> Result<(), serenity::Error> { match error { crate::FrameworkError::Setup { error, .. } => { - log::error!("Error in user data setup: {}", error); + eprintln!("Error in user data setup: {}", error); } - crate::FrameworkError::Listener { error, event, .. } => log::error!( - "User event listener encountered an error on {} event: {}", + crate::FrameworkError::EventHandler { error, event, .. } => log::error!( + "User event event handler encountered an error on {} event: {}", event.snake_case_name(), error ), crate::FrameworkError::Command { ctx, error } => { let error = error.to_string(); + eprintln!("An error occured in a command: {}", error); ctx.say(error).await?; } + crate::FrameworkError::SubcommandRequired { ctx } => { + let subcommands = ctx + .command() + .subcommands + .iter() + .map(|s| &*s.name) + .collect::>(); + let response = format!( + "You must specify one of the following subcommands: {}", + subcommands.join(", ") + ); + say_ephemeral(ctx, response.as_str(), true).await?; + } + crate::FrameworkError::CommandPanic { ctx, payload: _ } => { + // Not showing the payload to the user because it may contain sensitive info + ctx.send( + crate::CreateReply::default().embed( + serenity::CreateEmbed::default() + .title("Internal error") + .color(serenity::Color::RED) + .description("An unexpected internal error has occurred."), + ), + ) + .await?; + } crate::FrameworkError::ArgumentParse { ctx, input, error } => { // If we caught an argument parse error, give a helpful error message with the // command explanation if available - let usage = match ctx.command().help_text { - Some(help_text) => help_text(), - None => "Please check the help menu for usage information".into(), + let usage = match &ctx.command().help_text { + Some(help_text) => &**help_text, + None => "Please check the help menu for usage information", }; let response = if let Some(input) = input { format!( @@ -170,7 +201,7 @@ pub async fn on_error( interaction.data().name ); } - crate::FrameworkError::__NonExhaustive => panic!(), + crate::FrameworkError::__NonExhaustive(unreachable) => match unreachable {}, } Ok(()) @@ -179,6 +210,7 @@ pub async fn on_error( /// An autocomplete function that can be used for the command parameter in your help function. /// /// See `examples/framework_usage` for an example +#[allow(clippy::unused_async)] // Required for the return type pub async fn autocomplete_command<'a, U, E>( ctx: crate::Context<'a, U, E>, partial: &'a str, @@ -207,16 +239,7 @@ pub async fn autocomplete_command<'a, U, E>( pub async fn servers(ctx: crate::Context<'_, U, E>) -> Result<(), serenity::Error> { use std::fmt::Write as _; - let mut show_private_guilds = false; - if let crate::Context::Application(_) = ctx { - if let Ok(app) = ctx.discord().http.get_current_application_info().await { - if let Some(owner) = &app.owner { - if owner.id == ctx.author().id { - show_private_guilds = true; - } - } - } - } + let show_private_guilds = ctx.framework().options().owners.contains(&ctx.author().id); /// Stores details of a guild for the purposes of listing it in the bot guild list struct Guild { @@ -228,12 +251,12 @@ pub async fn servers(ctx: crate::Context<'_, U, E>) -> Result<(), serenity is_public: bool, } - let guild_ids = ctx.discord().cache.guilds(); + let guild_ids = ctx.cache().guilds(); let mut num_unavailable_guilds = 0; let mut guilds = guild_ids .iter() .map(|&guild_id| { - let guild = ctx.discord().cache.guild(guild_id)?; + let guild = ctx.cache().guild(guild_id)?; Some(Guild { name: guild.name.clone(), num_members: guild.member_count, diff --git a/src/builtins/paginate.rs b/src/builtins/paginate.rs new file mode 100644 index 000000000000..991148ec47d0 --- /dev/null +++ b/src/builtins/paginate.rs @@ -0,0 +1,91 @@ +//! Sample pagination implementation + +use crate::serenity_prelude as serenity; + +/// This is an example implementation of pagination. To tweak the behavior, copy the source code and +/// adjust to your needs: +/// - change embed appearance +/// - use different emojis for the navigation buttons +/// - add more navigation buttons +/// - change timeout duration +/// - add a page selector dropdown +/// - use reactions instead of buttons +/// - remove message after navigation timeout +/// - ... +/// +/// Note: this is a long-running function. It will only return once the timeout for navigation +/// button interactions has been reached. +/// +/// # Example +/// +/// ```rust,no_run +/// # async fn _test(ctx: poise::Context<'_, (), serenity::Error>) -> Result<(), serenity::Error> { +/// let pages = &[ +/// "Content of first page", +/// "Content of second page", +/// "Content of third page", +/// "Content of fourth page", +/// ]; +/// +/// poise::samples::paginate(ctx, pages).await?; +/// # Ok(()) } +/// ``` +/// +/// ![Screenshot of output](https://i.imgur.com/JGFDveA.png) +pub async fn paginate( + ctx: crate::Context<'_, U, E>, + pages: &[&str], +) -> Result<(), serenity::Error> { + // Define some unique identifiers for the navigation buttons + let ctx_id = ctx.id(); + let prev_button_id = format!("{}prev", ctx.id()); + let next_button_id = format!("{}next", ctx.id()); + + // Send the embed with the first page as content + let mut current_page = 0; + ctx.send( + crate::CreateReply::default() + .embed(serenity::CreateEmbed::default().description(pages[current_page])) + .components(vec![serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new(&prev_button_id).emoji('◀'), + serenity::CreateButton::new(&next_button_id).emoji('▶'), + ])]), + ) + .await?; + + // Loop through incoming interactions with the navigation buttons + while let Some(press) = serenity::ComponentInteractionCollector::new(ctx) + // We defined our button IDs to start with `ctx_id`. If they don't, some other command's + // button was pressed + .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) + // Timeout when no navigation button has been pressed for 24 hours + .timeout(std::time::Duration::from_secs(3600 * 24)) + .await + { + // Depending on which button was pressed, go to next or previous page + if press.data.custom_id == next_button_id { + current_page += 1; + if current_page >= pages.len() { + current_page = 0; + } + } else if press.data.custom_id == prev_button_id { + current_page = current_page.checked_sub(1).unwrap_or(pages.len() - 1); + } else { + // This is an unrelated button interaction + continue; + } + + // Update the message with the new page contents + press + .create_response( + ctx.serenity_context(), + serenity::CreateInteractionResponse::UpdateMessage( + serenity::CreateInteractionResponseMessage::default() + .embed(serenity::CreateEmbed::default().description(pages[current_page])), + ), + ) + .await?; + } + + Ok(()) +} diff --git a/src/builtins/register.rs b/src/builtins/register.rs index da67a478d19c..e9c15ea8dbc9 100644 --- a/src/builtins/register.rs +++ b/src/builtins/register.rs @@ -9,11 +9,11 @@ use crate::serenity_prelude as serenity; /// /// ```rust,no_run /// # use poise::serenity_prelude as serenity; -/// # async fn foo(ctx: poise::Context<'_, U, E>) -> Result<(), serenity::Error> { +/// # async fn foo(ctx: poise::Context<'_, (), ()>) -> Result<(), serenity::Error> { /// let commands = &ctx.framework().options().commands; /// let create_commands = poise::builtins::create_application_commands(commands); /// -/// serenity::Command::set_global_commands(ctx.discord(), create_commands).await?; +/// serenity::Command::set_global_commands(ctx, create_commands).await?; /// # Ok(()) } /// ``` pub fn create_application_commands( @@ -44,6 +44,34 @@ pub fn create_application_commands( } commands_builder } + +/// Registers the given list of application commands to Discord as global commands. +/// +/// Thin wrapper around [`create_application_commands`] that funnels the returned builder into +/// [`serenity::Command::set_global_commands`]. +pub async fn register_globally( + http: impl AsRef, + commands: &[crate::Command], +) -> Result<(), serenity::Error> { + let builder = create_application_commands(commands); + serenity::Command::set_global_commands(http, builder).await?; + Ok(()) +} + +/// Registers the given list of application commands to Discord as guild-specific commands. +/// +/// Thin wrapper around [`create_application_commands`] that funnels the returned builder into +/// [`serenity::GuildId::set_commands`]. +pub async fn register_in_guild( + http: impl AsRef, + commands: &[crate::Command], + guild_id: serenity::GuildId, +) -> Result<(), serenity::Error> { + let builder = create_application_commands(commands); + guild_id.set_commands(http, builder).await?; + Ok(()) +} + /// _Note: you probably want [`register_application_commands_buttons`] instead; it's easier and more /// powerful_ /// @@ -73,7 +101,7 @@ pub async fn register_application_commands( if global { ctx.say(format!("Registering {} commands...", num_commands)) .await?; - serenity::Command::set_global_commands(ctx.discord(), commands_builder).await?; + serenity::Command::set_global_commands(ctx, commands_builder).await?; } else { let guild_id = match ctx.guild_id() { Some(x) => x, @@ -85,9 +113,7 @@ pub async fn register_application_commands( ctx.say(format!("Registering {} commands...", num_commands)) .await?; - guild_id - .set_commands(ctx.discord(), commands_builder) - .await?; + guild_id.set_commands(ctx, commands_builder).await?; } ctx.say("Done!").await?; @@ -166,7 +192,7 @@ pub async fn register_application_commands_buttons( let interaction = reply .message() .await? - .await_component_interaction(&ctx.discord().shard) + .await_component_interaction(ctx) .author_id(ctx.author().id) .await; @@ -209,10 +235,10 @@ pub async fn register_application_commands_buttons( num_commands )) .await?; - serenity::Command::set_global_commands(ctx.discord(), create_commands).await?; + serenity::Command::set_global_commands(ctx, create_commands).await?; } else { ctx.say(":gear: Unregistering global commands...").await?; - serenity::Command::set_global_commands(ctx.discord(), Vec::new()).await?; + serenity::Command::set_global_commands(ctx, Vec::new()).await?; } } else { let guild_id = match ctx.guild_id() { @@ -228,12 +254,10 @@ pub async fn register_application_commands_buttons( num_commands )) .await?; - guild_id - .set_commands(ctx.discord(), create_commands) - .await?; + guild_id.set_commands(ctx, create_commands).await?; } else { ctx.say(":gear: Unregistering guild commands...").await?; - guild_id.set_commands(ctx.discord(), Vec::new()).await?; + guild_id.set_commands(ctx, Vec::new()).await?; } } diff --git a/src/choice_parameter.rs b/src/choice_parameter.rs new file mode 100644 index 000000000000..1cbee86ad927 --- /dev/null +++ b/src/choice_parameter.rs @@ -0,0 +1,85 @@ +//! Contains the [`ChoiceParameter`] trait and the blanket [`crate::SlashArgument`] and +//! [`crate::PopArgument`] impl + +use crate::serenity_prelude as serenity; + +/// 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; + + /// Returns an instance of [`Self`] corresponding to the given index into [`Self::list()`] + fn from_index(index: usize) -> Option; + + /// Parses the name as returned by [`Self::name()`] into an instance of [`Self`] + fn from_name(name: &str) -> Option; + + /// Returns the non-localized name of this choice + fn name(&self) -> &'static str; + + /// Returns the localized name for the given locale, if one is set + fn localized_name(&self, locale: &str) -> Option<&'static str>; +} + +#[async_trait::async_trait] +impl crate::SlashArgument for T { + async fn extract( + _: &impl serenity::CacheHttp, + _: crate::CommandOrAutocompleteInteraction<'_>, + value: &serenity::ResolvedValue<'_>, + ) -> Result { + #[allow(unused_imports)] + use ::serenity::json::prelude::*; // Required for simd-json :| + use std::convert::TryInto as _; + + let choice_key = match *value { + serenity::ResolvedValue::Integer(x) => { + x.try_into() + .map_err(|_| crate::SlashArgError::CommandStructureMismatch { + description: "received out of bounds integer", + }) + } + _ => Err(crate::SlashArgError::CommandStructureMismatch { + description: "expected integer", + }), + }?; + + Self::from_index(choice_key).ok_or(crate::SlashArgError::CommandStructureMismatch { + description: "out of bounds choice key", + }) + } + + fn create(builder: serenity::CreateCommandOption) -> serenity::CreateCommandOption { + builder.kind(serenity::CommandOptionType::Integer) + } + + fn choices() -> Vec { + Self::list() + } +} + +#[async_trait::async_trait] +impl<'a, T: ChoiceParameter> crate::PopArgument<'a> for T { + async fn pop_from( + args: &'a str, + attachment_index: usize, + ctx: &serenity::Context, + msg: &serenity::Message, + ) -> Result<(&'a str, usize, Self), (Box, Option)> + { + let (args, attachment_index, s) = + crate::pop_prefix_argument!(String, args, attachment_index, ctx, msg).await?; + + Ok(( + args, + attachment_index, + Self::from_name(&s).ok_or(( + Box::new(crate::InvalidChoice { + __non_exhaustive: (), + }) as Box, + Some(s), + ))?, + )) + } +} diff --git a/src/cooldown.rs b/src/cooldown.rs index ff0c5faee18f..34a01d54d9e5 100644 --- a/src/cooldown.rs +++ b/src/cooldown.rs @@ -2,9 +2,21 @@ use crate::serenity_prelude as serenity; // I usually don't really do imports, but these are very convenient -use crate::util::OrderedMap; +use std::collections::HashMap; use std::time::{Duration, Instant}; +/// Subset of [`crate::Context`] so that [`Cooldowns`] can be used without requiring a full [Context](`crate::Context`) +/// (ie from within an `event_handler`) +#[derive(Default, Clone, PartialEq, Eq, Debug, Hash)] +pub struct CooldownContext { + /// The user associated with this request + pub user_id: serenity::UserId, + /// The guild this request originated from or `None` + pub guild_id: Option, + /// The channel associated with this request + pub channel_id: serenity::ChannelId, +} + /// Configuration struct for [`Cooldowns`] #[derive(Default, Clone, PartialEq, Eq, Debug, Hash)] pub struct CooldownConfig { @@ -18,67 +30,71 @@ pub struct CooldownConfig { pub channel: Option, /// This cooldown operates on a per-member basis pub member: Option, + #[doc(hidden)] + pub __non_exhaustive: (), } -/// Handles cooldowns for a single command +/// Tracks all types of cooldowns for a single command /// /// You probably don't need to use this directly. `#[poise::command]` automatically generates a /// cooldown handler. -#[derive(Default, Clone, Debug, PartialEq, Eq, Hash)] -pub struct Cooldowns { - /// Stores the cooldown durations - cooldown: CooldownConfig, - +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct CooldownTracker { /// Stores the timestamp of the last global invocation global_invocation: Option, /// Stores the timestamps of the last invocation per user - user_invocations: OrderedMap, + user_invocations: HashMap, /// Stores the timestamps of the last invocation per guild - guild_invocations: OrderedMap, + guild_invocations: HashMap, /// Stores the timestamps of the last invocation per channel - channel_invocations: OrderedMap, + channel_invocations: HashMap, /// Stores the timestamps of the last invocation per member (user and guild) - member_invocations: OrderedMap<(serenity::UserId, serenity::GuildId), Instant>, + member_invocations: HashMap<(serenity::UserId, serenity::GuildId), Instant>, } -impl Cooldowns { - /// Create a new cooldown handler with the given cooldown durations - pub fn new(config: CooldownConfig) -> Self { - Self { - cooldown: config, +/// **Renamed to [`CooldownTracker`]** +pub use CooldownTracker as Cooldowns; +impl CooldownTracker { + /// Create a new cooldown tracker + pub fn new() -> Self { + Self { global_invocation: None, - user_invocations: OrderedMap::new(), - guild_invocations: OrderedMap::new(), - channel_invocations: OrderedMap::new(), - member_invocations: OrderedMap::new(), + user_invocations: HashMap::new(), + guild_invocations: HashMap::new(), + channel_invocations: HashMap::new(), + member_invocations: HashMap::new(), } } /// Queries the cooldown buckets and checks if all cooldowns have expired and command /// execution may proceed. If not, Some is returned with the remaining cooldown - pub fn remaining_cooldown(&self, ctx: crate::Context<'_, U, E>) -> Option { + pub fn remaining_cooldown( + &self, + ctx: CooldownContext, + cooldown_durations: &CooldownConfig, + ) -> Option { let mut cooldown_data = vec![ - (self.cooldown.global, self.global_invocation), + (cooldown_durations.global, self.global_invocation), ( - self.cooldown.user, - self.user_invocations.get(&ctx.author().id).copied(), + cooldown_durations.user, + self.user_invocations.get(&ctx.user_id).copied(), ), ( - self.cooldown.channel, - self.channel_invocations.get(&ctx.channel_id()).copied(), + cooldown_durations.channel, + self.channel_invocations.get(&ctx.channel_id).copied(), ), ]; - if let Some(guild_id) = ctx.guild_id() { + if let Some(guild_id) = ctx.guild_id { cooldown_data.push(( - self.cooldown.guild, + cooldown_durations.guild, self.guild_invocations.get(&guild_id).copied(), )); cooldown_data.push(( - self.cooldown.member, + cooldown_durations.member, self.member_invocations - .get(&(ctx.author().id, guild_id)) + .get(&(ctx.user_id, guild_id)) .copied(), )); } @@ -94,17 +110,26 @@ impl Cooldowns { } /// Indicates that a command has been executed and all associated cooldowns should start running - pub fn start_cooldown(&mut self, ctx: crate::Context<'_, U, E>) { + pub fn start_cooldown(&mut self, ctx: CooldownContext) { let now = Instant::now(); self.global_invocation = Some(now); - self.user_invocations.insert(ctx.author().id, now); - self.channel_invocations.insert(ctx.channel_id(), now); + self.user_invocations.insert(ctx.user_id, now); + self.channel_invocations.insert(ctx.channel_id, now); - if let Some(guild_id) = ctx.guild_id() { + if let Some(guild_id) = ctx.guild_id { self.guild_invocations.insert(guild_id, now); - self.member_invocations - .insert((ctx.author().id, guild_id), now); + self.member_invocations.insert((ctx.user_id, guild_id), now); + } + } +} + +impl<'a> From<&'a serenity::Message> for CooldownContext { + fn from(message: &'a serenity::Message) -> Self { + Self { + user_id: message.author.id, + channel_id: message.channel_id, + guild_id: message.guild_id, } } } diff --git a/src/dispatch/common.rs b/src/dispatch/common.rs index 32cc7c03aa1c..a9e099023761 100644 --- a/src/dispatch/common.rs +++ b/src/dispatch/common.rs @@ -15,16 +15,7 @@ async fn user_permissions( None => return Some(serenity::Permissions::all()), // no permission checks in DMs }; - #[cfg(feature = "cache")] - let guild = match ctx.cache.guild(guild_id) { - Some(x) => x.clone(), - None => return None, // Guild not in cache - }; - #[cfg(not(feature = "cache"))] - let guild = match ctx.http.get_guild(guild_id).await { - Ok(x) => x, - Err(_) => return None, - }; + 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 { @@ -38,19 +29,7 @@ async fn user_permissions( Err(_) => return None, }; - #[cfg(feature = "cache")] - let cached_member = guild.members.get(&user_id).cloned(); - #[cfg(not(feature = "cache"))] - let cached_member = None; - - // If member not in cache (probably because presences intent is not enabled), retrieve via HTTP - let member = match cached_member { - Some(x) => x, - None => match ctx.http.get_member(guild_id, user_id).await { - Ok(member) => member, - Err(_) => return None, - }, - }; + let member = guild.member(ctx, user_id).await.ok()?; Some(guild.user_permissions_in(&channel, &member)) } @@ -67,7 +46,13 @@ async fn missing_permissions( return Some(serenity::Permissions::empty()); } - let permissions = user_permissions(ctx.discord(), ctx.guild_id(), ctx.channel_id(), user).await; + let permissions = user_permissions( + ctx.serenity_context(), + ctx.guild_id(), + ctx.channel_id(), + user, + ) + .await; Some(required_permissions - permissions?) } @@ -77,6 +62,13 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( ctx: crate::Context<'a, U, E>, cmd: &'a crate::Command, ) -> Result<(), crate::FrameworkError<'a, U, E>> { + // Skip command checks if `FrameworkOptions::skip_checks_for_owners` is set to true + if ctx.framework().options.skip_checks_for_owners + && ctx.framework().options().owners.contains(&ctx.author().id) + { + return Ok(()); + } + if cmd.owners_only && !ctx.framework().options().owners.contains(&ctx.author().id) { return Err(crate::FrameworkError::NotAnOwner { ctx }); } @@ -87,7 +79,7 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( Some(guild_id) => { #[cfg(feature = "cache")] if ctx.framework().options().require_cache_for_guild_check - && ctx.discord().cache.guild(guild_id).is_none() + && ctx.cache().guild(guild_id).is_none() { return Err(crate::FrameworkError::GuildOnly { ctx }); } @@ -102,7 +94,7 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( } if cmd.nsfw_only { - let channel = match ctx.channel_id().to_channel(ctx.discord()).await { + let channel = match ctx.channel_id().to_channel(ctx.serenity_context()).await { Ok(channel) => channel, Err(e) => { log::warn!("Error when getting channel: {}", e); @@ -148,8 +140,8 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( None => {} } - // Only continue if command checks returns true. First perform global checks, then command - // checks (if necessary) + // Only continue if command checks returns true + // First perform global checks, then command checks (if necessary) for check in Option::iter(&ctx.framework().options().command_check).chain(&cmd.checks) { match check(ctx).await { Ok(true) => {} @@ -166,8 +158,12 @@ async fn check_permissions_and_cooldown_single<'a, U, E>( } if !ctx.framework().options().manual_cooldowns { - let cooldowns = &cmd.cooldowns; - let remaining_cooldown = cooldowns.lock().unwrap().remaining_cooldown(ctx); + let cooldown_tracker = &cmd.cooldowns; + let cooldown_config = cmd.cooldown_config.lock().unwrap(); + let remaining_cooldown = cooldown_tracker + .lock() + .unwrap() + .remaining_cooldown(ctx.cooldown_context(), &cooldown_config); if let Some(remaining_cooldown) = remaining_cooldown { return Err(crate::FrameworkError::CooldownHit { ctx, diff --git a/src/dispatch/mod.rs b/src/dispatch/mod.rs index 8a64b31e6771..3fc3443dead5 100644 --- a/src/dispatch/mod.rs +++ b/src/dispatch/mod.rs @@ -33,16 +33,26 @@ impl Clone for FrameworkContext<'_, U, E> { } impl<'a, U, E> FrameworkContext<'a, U, E> { /// Returns the stored framework options, including commands. + /// + /// This function exists for API compatiblity with [`crate::Framework`]. On this type, you can + /// also just access the public `options` field. pub fn options(&self) -> &'a crate::FrameworkOptions { self.options } /// Returns the serenity's client shard manager. + /// + /// This function exists for API compatiblity with [`crate::Framework`]. On this type, you can + /// also just access the public `shard_manager` field. pub fn shard_manager(&self) -> std::sync::Arc { self.shard_manager.clone() } /// Retrieves user data + /// + /// This function exists for API compatiblity with [`crate::Framework`]. On this type, you can + /// also just access the public `user_data` field. + #[allow(clippy::unused_async)] // for API compatibility with Framework pub async fn user_data(&self) -> &'a U { self.user_data } @@ -85,8 +95,8 @@ pub async fn dispatch_event( let invocation_data = tokio::sync::Mutex::new(Box::new(()) as _); let mut parent_commands = Vec::new(); let trigger = match previously_tracked { - true => crate::MessageDispatchTrigger::MessageEditFromInvalid, - false => crate::MessageDispatchTrigger::MessageEdit, + true => crate::MessageDispatchTrigger::MessageEdit, + false => crate::MessageDispatchTrigger::MessageEditFromInvalid, }; if let Err(error) = prefix::dispatch_message( framework, @@ -103,6 +113,23 @@ pub async fn dispatch_event( } } } + serenity::FullEvent::MessageDelete { + ctx, + deleted_message_id, + .. + } => { + if let Some(edit_tracker) = &framework.options.prefix_options.edit_tracker { + let bot_response = edit_tracker + .write() + .unwrap() + .process_message_delete(*deleted_message_id); + if let Some(bot_response) = bot_response { + if let Err(e) = bot_response.delete(ctx).await { + log::warn!("failed to delete bot response: {}", e); + } + } + } + } serenity::FullEvent::InteractionCreate { ctx, interaction: serenity::Interaction::Command(interaction), @@ -149,9 +176,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.listener)(event, framework, framework.user_data().await).await + (framework.options.event_handler)(event, framework, framework.user_data).await { - let error = crate::FrameworkError::Listener { + let error = crate::FrameworkError::EventHandler { error, event, framework, diff --git a/src/dispatch/prefix.rs b/src/dispatch/prefix.rs index 7e0ba4251d9d..0eec09e3445a 100644 --- a/src/dispatch/prefix.rs +++ b/src/dispatch/prefix.rs @@ -14,9 +14,10 @@ async fn strip_prefix<'a, U, E>( guild_id: msg.guild_id, channel_id: msg.channel_id, author: &msg.author, - discord: ctx, + serenity_context: ctx, framework, - data: framework.user_data().await, + data: framework.user_data, + __non_exhaustive: (), }; if let Some(dynamic_prefix) = framework.options.prefix_options.dynamic_prefix { @@ -60,13 +61,14 @@ async fn strip_prefix<'a, U, E>( None } } + crate::Prefix::__NonExhaustive => unreachable!(), }) { return Some((prefix, content)); } if let Some(dynamic_prefix) = framework.options.prefix_options.stripped_dynamic_prefix { - match dynamic_prefix(ctx, msg, framework.user_data().await).await { + match dynamic_prefix(ctx, msg, framework.user_data).await { Ok(result) => { if let Some((prefix, content)) = result { return Some((prefix, content)); @@ -141,10 +143,7 @@ pub fn find_command<'a, U, E>( remaining_message: &'a str, case_insensitive: bool, parent_commands: &mut Vec<&'a crate::Command>, -) -> Option<(&'a crate::Command, &'a str, &'a str)> -where - U: Send + Sync, -{ +) -> Option<(&'a crate::Command, &'a str, &'a str)> { let string_equal = if case_insensitive { |a: &str, b: &str| a.eq_ignore_ascii_case(b) } else { @@ -203,7 +202,12 @@ pub async fn dispatch_message<'a, U: Send + Sync, E>( ) .await? { - run_invocation(ctx).await?; + crate::catch_unwind_maybe(run_invocation(ctx)) + .await + .map_err(|payload| crate::FrameworkError::CommandPanic { + payload, + ctx: ctx.into(), + })??; } Ok(()) } @@ -251,6 +255,7 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( invocation_data, trigger, })?; + let action = match command.prefix_action { Some(x) => x, // This command doesn't have a prefix implementation @@ -258,13 +263,13 @@ pub async fn parse_invocation<'a, U: Send + Sync, E>( }; Ok(Some(crate::PrefixContext { - discord: ctx, + serenity_context: ctx, msg, prefix, invoked_command_name, args, framework, - data: framework.user_data().await, + data: framework.user_data, parent_commands, command, invocation_data, @@ -289,11 +294,19 @@ pub async fn run_invocation( return Ok(()); } + if ctx.command.subcommand_required { + // None of this command's subcommands were invoked, or else we'd have the subcommand in + // ctx.command and not the parent command + return Err(crate::FrameworkError::SubcommandRequired { + ctx: crate::Context::Prefix(ctx), + }); + } + super::common::check_permissions_and_cooldown(ctx.into()).await?; // Typing is broadcasted as long as this object is alive let _typing_broadcaster = if ctx.command.broadcast_typing { - Some(ctx.msg.channel_id.start_typing(&ctx.discord.http)) + Some(ctx.msg.channel_id.start_typing(&ctx.serenity_context.http)) } else { None }; @@ -305,7 +318,10 @@ pub async fn run_invocation( // execute_untracked_edits situation and start an infinite loop // Reported by vicky5124 https://discord.com/channels/381880193251409931/381912587505500160/897981367604903966 if let Some(edit_tracker) = &ctx.framework.options.prefix_options.edit_tracker { - edit_tracker.write().unwrap().track_command(ctx.msg); + edit_tracker + .write() + .unwrap() + .track_command(ctx.msg, ctx.command.track_deletion); } // Execute command diff --git a/src/dispatch/slash.rs b/src/dispatch/slash.rs index 5041bcd0fecc..885afbb958c6 100644 --- a/src/dispatch/slash.rs +++ b/src/dispatch/slash.rs @@ -10,7 +10,9 @@ fn find_matching_command<'a, 'b, U, E>( parent_commands: &mut Vec<&'a crate::Command>, ) -> Option<(&'a crate::Command, &'b [serenity::ResolvedOption<'b>])> { commands.iter().find_map(|cmd| { - if interaction_name != cmd.name && Some(interaction_name) != cmd.context_menu_name { + if interaction_name != cmd.name + && Some(interaction_name) != cmd.context_menu_name.as_deref() + { return None; } @@ -35,9 +37,11 @@ fn find_matching_command<'a, 'b, U, E>( }) } -/// Given an interaction, finds the matching framework command and checks if the user is allowed -/// access -pub async fn extract_command_and_run_checks<'a, U, E>( +/// Parses an `Interaction` into a [`crate::ApplicationContext`] using some context data. +/// +/// After this, the [`crate::ApplicationContext`] should be passed into [`run_command`] or +/// [`run_autocomplete`]. +fn extract_command<'a, U, E>( framework: crate::FrameworkContext<'a, U, E>, ctx: &'a serenity::Context, interaction: crate::CommandOrAutocompleteInteraction<'a>, @@ -60,9 +64,9 @@ pub async fn extract_command_and_run_checks<'a, U, E>( interaction, })?; - let ctx = crate::ApplicationContext { - data: framework.user_data().await, - discord: ctx, + Ok(crate::ApplicationContext { + data: framework.user_data, + serenity_context: ctx, framework, interaction, args: leaf_interaction_options, @@ -71,28 +75,22 @@ pub async fn extract_command_and_run_checks<'a, U, E>( has_sent_initial_response, invocation_data, __non_exhaustive: (), - }; - - super::common::check_permissions_and_cooldown(ctx.into()).await?; - - Ok(ctx) + }) } -/// Dispatches this interaction onto framework commands, i.e. runs the associated command -pub async fn dispatch_interaction<'a, U, E>( +/// Given an interaction, finds the matching framework command and checks if the user is allowed +/// access +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, - // Need to pass this in from outside because of lifetime issues + // need to pass the following in for lifetime reasons has_sent_initial_response: &'a std::sync::atomic::AtomicBool, - // Need to pass this in from outside because of lifetime issues invocation_data: &'a tokio::sync::Mutex>, - // Need to pass this in from outside because of lifetime issues options: &'a [serenity::ResolvedOption<'a>], - // Need to pass this in from outside because of lifetime issues parent_commands: &'a mut Vec<&'a crate::Command>, -) -> Result<(), crate::FrameworkError<'a, U, E>> { - let ctx = extract_command_and_run_checks( +) -> Result, crate::FrameworkError<'a, U, E>> { + let ctx = extract_command( framework, ctx, crate::CommandOrAutocompleteInteraction::Command(interaction), @@ -100,10 +98,19 @@ pub async fn dispatch_interaction<'a, U, E>( invocation_data, options, parent_commands, - ) - .await?; + )?; + super::common::check_permissions_and_cooldown(ctx.into()).await?; + Ok(ctx) +} - (framework.options.pre_command)(crate::Context::Application(ctx)).await; +/// Given the extracted application command data from [`extract_command`], runs the command, +/// including all the before and after code like checks. +async fn run_command( + ctx: crate::ApplicationContext<'_, U, E>, +) -> Result<(), crate::FrameworkError<'_, U, E>> { + super::common::check_permissions_and_cooldown(ctx.into()).await?; + + (ctx.framework.options.pre_command)(crate::Context::Application(ctx)).await; // Check which interaction type we received and grab the command action and, if context menu, // the resolved click target, and execute the action @@ -112,7 +119,7 @@ pub async fn dispatch_interaction<'a, U, E>( description: "received interaction type but command contained no \ matching action or interaction contained no matching context menu object", }; - let action_result = match interaction.data.kind { + let action_result = match ctx.interaction.data().kind { serenity::CommandType::ChatInput => { let action = ctx .command @@ -121,20 +128,26 @@ pub async fn dispatch_interaction<'a, U, E>( action(ctx).await } serenity::CommandType::User => { - match (ctx.command.context_menu_action, interaction.data.target()) { + match ( + ctx.command.context_menu_action, + &ctx.interaction.data().target(), + ) { ( Some(crate::ContextMenuCommandAction::User(action)), Some(serenity::ResolvedTarget::User(user, _)), - ) => action(ctx, user.clone()).await, + ) => action(ctx, (*user).clone()).await, _ => return Err(command_structure_mismatch_error), } } serenity::CommandType::Message => { - match (ctx.command.context_menu_action, interaction.data.target()) { + match ( + ctx.command.context_menu_action, + &ctx.interaction.data().target(), + ) { ( Some(crate::ContextMenuCommandAction::Message(action)), Some(serenity::ResolvedTarget::Message(message)), - ) => action(ctx, message.clone()).await, + ) => action(ctx, (*message).clone()).await, _ => return Err(command_structure_mismatch_error), } } @@ -145,36 +158,48 @@ pub async fn dispatch_interaction<'a, U, E>( }; action_result?; - (framework.options.post_command)(crate::Context::Application(ctx)).await; + (ctx.framework.options.post_command)(crate::Context::Application(ctx)).await; Ok(()) } -/// Dispatches this interaction onto framework commands, i.e. runs the associated autocomplete -/// callback -pub async fn dispatch_autocomplete<'a, U, E>( +/// 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 + // need to pass the following in for lifetime reasons has_sent_initial_response: &'a std::sync::atomic::AtomicBool, - // Need to pass this in from outside because of lifetime issues invocation_data: &'a tokio::sync::Mutex>, - // Need to pass this in from outside because of lifetime issues options: &'a [serenity::ResolvedOption<'a>], - // Need to pass this in from outside because of lifetime issues parent_commands: &'a mut Vec<&'a crate::Command>, ) -> Result<(), crate::FrameworkError<'a, U, E>> { - let ctx = extract_command_and_run_checks( + let ctx = extract_command( framework, ctx, - crate::CommandOrAutocompleteInteraction::Autocomplete(interaction), + crate::CommandOrAutocompleteInteraction::Command(interaction), has_sent_initial_response, invocation_data, options, parent_commands, - ) - .await?; + )?; + + crate::catch_unwind_maybe(run_command(ctx)) + .await + .map_err(|payload| crate::FrameworkError::CommandPanic { + payload, + ctx: ctx.into(), + })??; + + Ok(()) +} + +/// Given the extracted application command data from [`extract_command`], runs the autocomplete +/// callbacks, including all the before and after code like checks. +async fn run_autocomplete( + ctx: crate::ApplicationContext<'_, U, E>, +) -> Result<(), crate::FrameworkError<'_, U, E>> { + super::common::check_permissions_and_cooldown(ctx.into()).await?; // Find which parameter is focused by the user let (focused_option_name, partial_input) = match ctx.args.iter().find_map(|o| match &o.value { @@ -192,7 +217,13 @@ pub async fn dispatch_autocomplete<'a, U, E>( let parameters = &ctx.command.parameters; let focused_parameter = parameters .iter() - .find(|p| p.name == *focused_option_name) + .find(|p| match &p.name { + Some(param_name) => *param_name == *focused_option_name, + None => { + log::warn!("name-less parameter ended up in slash command"); + false + } + }) .ok_or(crate::FrameworkError::CommandStructureMismatch { ctx, description: "focused autocomplete parameter name not recognized", @@ -216,10 +247,18 @@ pub async fn dispatch_autocomplete<'a, U, E>( } }; + let interaction = match ctx.interaction { + crate::CommandOrAutocompleteInteraction::Autocomplete(x) => x, + _ => { + log::warn!("a non-autocomplete interaction was given to run_autocomplete()"); + return Ok(()); + } + }; + // Send the generates autocomplete response if let Err(e) = interaction .create_response( - &ctx.discord.http, + &ctx.serenity_context.http, serenity::CreateInteractionResponse::Autocomplete(autocomplete_response), ) .await @@ -229,3 +268,35 @@ pub async fn dispatch_autocomplete<'a, U, E>( Ok(()) } + +/// Dispatches this interaction onto framework commands, i.e. runs the associated 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 for lifetime reasons + has_sent_initial_response: &'a std::sync::atomic::AtomicBool, + invocation_data: &'a tokio::sync::Mutex>, + options: &'a [serenity::ResolvedOption<'a>], + parent_commands: &'a mut Vec<&'a crate::Command>, +) -> Result<(), crate::FrameworkError<'a, U, E>> { + let ctx = extract_command( + framework, + ctx, + crate::CommandOrAutocompleteInteraction::Autocomplete(interaction), + has_sent_initial_response, + invocation_data, + options, + parent_commands, + )?; + + crate::catch_unwind_maybe(run_autocomplete(ctx)) + .await + .map_err(|payload| crate::FrameworkError::CommandPanic { + payload, + ctx: ctx.into(), + })??; + + Ok(()) +} diff --git a/src/framework/builder.rs b/src/framework/builder.rs index 3985041ab6b8..6195c40f9d48 100644 --- a/src/framework/builder.rs +++ b/src/framework/builder.rs @@ -7,15 +7,15 @@ use crate::BoxFuture; /// /// If one of the following required values is missing, the builder will panic on start: /// - [`Self::token`] -/// - [`Self::user_data_setup`] +/// - [`Self::setup`] /// - [`Self::options`] /// - [`Self::intents`] /// /// Before starting, the builder will make an HTTP request to retrieve the bot's application ID and /// owner, if [`Self::initialize_owners`] is set (true by default). pub struct FrameworkBuilder { - /// Callback for user data setup - user_data_setup: Option< + /// Callback for startup code and user data creation + setup: Option< Box< dyn Send + Sync @@ -44,7 +44,7 @@ pub struct FrameworkBuilder { impl Default for FrameworkBuilder { fn default() -> Self { Self { - user_data_setup: Default::default(), + setup: Default::default(), options: Default::default(), client_settings: Default::default(), token: Default::default(), @@ -63,9 +63,9 @@ impl FrameworkBuilder { panic!("Please set the prefix via FrameworkOptions::prefix_options::prefix"); } - /// Set a callback to be invoked to create the user data instance + /// Sets the setup callback which also returns the user data struct. #[must_use] - pub fn user_data_setup(mut self, user_data_setup: F) -> Self + pub fn setup(mut self, setup: F) -> Self where F: Send + Sync @@ -76,10 +76,27 @@ impl FrameworkBuilder { &'a crate::Framework, ) -> BoxFuture<'a, Result>, { - self.user_data_setup = Some(Box::new(user_data_setup) as _); + self.setup = Some(Box::new(setup) as _); self } + /// Sets the setup callback which also returns the user data struct. + #[must_use] + #[deprecated = "renamed to .setup()"] + pub fn user_data_setup(self, setup: F) -> Self + where + F: Send + + Sync + + 'static + + for<'a> FnOnce( + &'a serenity::Context, + &'a serenity::Ready, + &'a crate::Framework, + ) -> BoxFuture<'a, Result>, + { + self.setup(setup) + } + /// Configure framework options #[must_use] pub fn options(mut self, options: crate::FrameworkOptions) -> Self { @@ -183,8 +200,8 @@ and enable MESSAGE_CONTENT in your Discord bot dashboard ", ); - let user_data_setup = self - .user_data_setup + let setup = self + .setup .expect("No user data setup function was provided to the framework"); let mut options = self.options.expect("No framework options provided"); @@ -194,7 +211,7 @@ and enable MESSAGE_CONTENT in your Discord bot dashboard // Create serenity client let mut client_builder = serenity::ClientBuilder::new(token, intents) - .framework(crate::Framework::new(options, user_data_setup)); + .framework(crate::Framework::new(options, setup)); if let Some(client_settings) = self.client_settings { client_builder = client_settings(client_builder); } diff --git a/src/framework/mod.rs b/src/framework/mod.rs index 001ded8599f4..ebcad4fdcabe 100644 --- a/src/framework/mod.rs +++ b/src/framework/mod.rs @@ -27,7 +27,7 @@ pub struct Framework { /// Initialized during construction; so shouldn't be None at any observable point shard_manager: once_cell::sync::OnceCell>, /// Filled with Some on construction. Taken out and executed on first Ready gateway event - user_data_setup: std::sync::Mutex< + setup: std::sync::Mutex< Option< Box< dyn Send @@ -70,7 +70,7 @@ impl Framework { /// user ID or connected guilds can be made available to the user data setup function. The user /// data setup is not allowed to return Result because there would be no reasonable /// course of action on error. - pub fn new(options: crate::FrameworkOptions, user_data_setup: F) -> Self + pub fn new(options: crate::FrameworkOptions, setup: F) -> Self where F: Send + Sync @@ -86,7 +86,7 @@ impl Framework { Self { user_data: once_cell::sync::OnceCell::new(), bot_id: once_cell::sync::OnceCell::new(), - user_data_setup: std::sync::Mutex::new(Some(Box::new(user_data_setup))), + setup: std::sync::Mutex::new(Some(Box::new(setup))), options, shard_manager: once_cell::sync::OnceCell::new(), edit_tracker_purge_task: once_cell::sync::OnceCell::new(), @@ -181,9 +181,9 @@ where } = event { let _: Result<_, _> = framework.bot_id.set(data_about_bot.user.id); - let user_data_setup = Option::take(&mut *framework.user_data_setup.lock().unwrap()); - if let Some(user_data_setup) = user_data_setup { - match user_data_setup(ctx, data_about_bot, framework).await { + let setup = Option::take(&mut *framework.setup.lock().unwrap()); + if let Some(setup) = setup { + match setup(ctx, data_about_bot, framework).await { Ok(user_data) => { let _: Result<_, _> = framework.user_data.set(user_data); } diff --git a/src/lib.rs b/src/lib.rs index 193a919b1e81..a1ae06a68766 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ #![cfg_attr(doc_nightly, feature(doc_cfg, doc_auto_cfg))] #![doc(test(attr(deny(deprecated))))] -#![warn(rust_2018_idioms)] -#![warn(missing_docs)] -#![warn(clippy::missing_docs_in_private_items)] -#![allow(clippy::type_complexity)] // native #[non_exhaustive] is awful because you can't do struct update syntax with it (??) #![allow(clippy::manual_non_exhaustive)] // I don't want to have inconsistent style for when expr is an ident vs not #![allow(clippy::uninlined_format_args)] +#![allow(clippy::type_complexity)] +#![warn( + clippy::missing_docs_in_private_items, + clippy::unused_async, + rust_2018_idioms, + missing_docs +)] /*! Poise is an opinionated Discord bot framework with a few distinctive features: @@ -38,9 +41,8 @@ means using serenity, so here's a couple tips: ## `impl Trait` parameters Many serenity functions take an argument of type [`impl CacheHttp`](serenity::CacheHttp) or -[`impl AsRef`](serenity::Http). Here, you commonly pass in -[`&serenity::Context`](serenity::Context), which you can get from -[`poise::Context`](crate::Context) via [`ctx.discord()`](crate::Context::discord) +[`impl AsRef`](serenity::Http). You can pass in any type that imlements these traits, like +[`crate::Context`] or [`serenity::Context`]. ## Gateway intents @@ -59,8 +61,8 @@ To set multiple gateway events, use the OR operator: You can run Discord actions outside of commands by cloning and storing [`serenity::CacheHttp`]/ [`Arc`](serenity::Http)/[`Arc`](serenity::Cache). You can get those either from [`serenity::Context`] (passed to -[`user_data_setup`](crate::FrameworkBuilder::user_data_setup) and all commands via -[`ctx.discord()`](crate::Context::discord)) or before starting the client via +[`setup`](crate::FrameworkBuilder::setup) and all commands via +[`ctx.serenity_framework()`](crate::Context::discord)) or before starting the client via [`http`](serenity::Client::http) and [`cache`](serenity::Client::cache). Pass your `CacheHttp` or `Arc` to serenity functions in place of the usual @@ -205,7 +207,7 @@ async fn my_huge_ass_command( fn my_huge_ass_command_help() -> String { String::from("\ Example usage: -~my_huge_ass_command 127.0.0.1 @kangalioo `i = i + 1` my_flag rest of the message") +~my_huge_ass_command 127.0.0.1 @kangalio `i = i + 1` my_flag rest of the message") } async fn check(ctx: Context<'_>) -> Result { @@ -284,7 +286,7 @@ functions manually: - [`serenity::Command::set_global_commands`] - [`serenity::GuildId::set_commands`] -For example, you could call this function in [`FrameworkBuilder::user_data_setup`] to automatically +For example, you could call this function in [`FrameworkBuilder::setup`] to automatically register commands on startup. Also see the docs of [`builtins::create_application_commands`]. The lowest level of abstraction for registering commands is [`Command::create_as_slash_command`] @@ -315,6 +317,70 @@ serenity::Member, serenity::UserId, serenity::ReactionType, serenity::GatewayInt # ); ``` +## Unit testing + +Unit testing a Discord bot is difficult, because mocking the Discord API is an uphill battle. +Your best bet for unit testing a Discord bot is to extract the "business logic" into a separate +function - the part of your commands that doesn't call serenity functions - and unit test that. + +Example: + +```rust +# type Error = Box; +# type Context<'a> = poise::Context<'a, (), Error>; +#[poise::command(slash_command)] +pub async fn calc(ctx: Context<'_>, expr: String) -> Result<(), Error> { + let ops: &[(char, fn(f64, f64) -> f64)] = &[ + ('+', |a, b| a + b), ('-', |a, b| a - b), ('*', |a, b| a * b), ('/', |a, b| a / b) + ]; + for &(operator, operator_fn) in ops { + if let Some((a, b)) = expr.split_once(operator) { + let result: f64 = (operator_fn)(a.trim().parse()?, b.trim().parse()?); + ctx.say(format!("Result: {}", result)).await?; + return Ok(()); + } + } + ctx.say("No valid operator found in expression!").await?; + Ok(()) +} +``` + +Can be transformed into + +```rust +# type Error = Box; +# type Context<'a> = poise::Context<'a, (), Error>; +fn calc_inner(expr: &str) -> Option { + let ops: &[(char, fn(f64, f64) -> f64)] = &[ + ('+', |a, b| a + b), ('-', |a, b| a - b), ('*', |a, b| a * b), ('/', |a, b| a / b) + ]; + for &(operator, operator_fn) in ops { + if let Some((a, b)) = expr.split_once(operator) { + let result: f64 = (operator_fn)(a.trim().parse().ok()?, b.trim().parse().ok()?); + return Some(result); + } + } + None +} + +#[poise::command(slash_command)] +pub async fn calc(ctx: Context<'_>, expr: String) -> Result<(), Error> { + match calc_inner(&expr) { + Some(result) => ctx.say(format!("Result: {}", result)).await?, + None => ctx.say("Failed to evaluate expression!").await?, + }; + Ok(()) +} + +// Now we can test the function!!! +#[test] +fn test_calc() { + assert_eq!(calc_inner("4 + 5"), Some(9.0)); + assert_eq!(calc_inner("4 / 5"), Some(0.2)); + assert_eq!(calc_inner("4 ^ 5"), None); +} +``` + # About the weird name I'm bad at names. Google lists "poise" as a synonym to "serenity" which is the Discord library underlying this framework, so that's what I chose. @@ -323,6 +389,7 @@ Also, poise is a stat in Dark Souls */ pub mod builtins; +pub mod choice_parameter; pub mod cooldown; pub mod dispatch; pub mod framework; @@ -341,8 +408,8 @@ pub mod macros { #[doc(no_inline)] pub use { - cooldown::*, dispatch::*, framework::*, macros::*, modal::*, prefix_argument::*, reply::*, - slash_argument::*, structs::*, track_edits::*, + choice_parameter::*, cooldown::*, dispatch::*, framework::*, macros::*, modal::*, + prefix_argument::*, reply::*, slash_argument::*, structs::*, track_edits::*, }; /// See [`builtins`] @@ -361,3 +428,33 @@ use serenity_prelude as serenity; // private alias for crate root docs intradoc- /// /// An owned future has the `'static` lifetime. pub type BoxFuture<'a, T> = std::pin::Pin + Send + 'a>>; + +/// Internal wrapper function for catch_unwind that respects the `handle_panics` feature flag +async fn catch_unwind_maybe( + fut: impl std::future::Future, +) -> Result> { + #[cfg(feature = "handle_panics")] + let res = futures_util::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(fut)) + .await + .map_err(|e| { + if let Some(s) = e.downcast_ref::<&str>() { + Some(s.to_string()) + } else if let Ok(s) = e.downcast::() { + Some(*s) + } else { + None + } + }); + #[cfg(not(feature = "handle_panics"))] + let res = Ok(fut.await); + res +} + +#[cfg(test)] +mod tests { + fn _assert_send_sync() {} + + fn _test_framework_error_send_sync<'a, U: Send + Sync + 'static, E: Send + Sync + 'static>() { + _assert_send_sync::>(); + } +} diff --git a/src/modal.rs b/src/modal.rs index db41dcaa5c64..5e4930af86be 100644 --- a/src/modal.rs +++ b/src/modal.rs @@ -1,5 +1,7 @@ //! Modal trait and utility items for implementing it (mainly for the derive macro) +use std::sync::Arc; + use crate::serenity_prelude as serenity; /// Meant for use in derived [`Modal::parse`] implementation @@ -38,36 +40,102 @@ pub fn find_modal_text( None } -/// See [`Modal::execute`] -async fn execute( - ctx: crate::ApplicationContext<'_, U, E>, +/// Underlying code for the modal spawning convenience function which abstracts over the kind of +/// interaction +async fn execute_modal_generic< + M: Modal, + F: std::future::Future>, +>( + ctx: &serenity::Context, + create_interaction_response: impl FnOnce(serenity::CreateInteractionResponse) -> F, + modal_custom_id: String, defaults: Option, -) -> Result { - let interaction = ctx.interaction.unwrap(); - let interaction_id = interaction.id.to_string(); - + timeout: Option, +) -> Result, serenity::Error> { // Send modal - interaction - .create_response(ctx.discord, M::create(defaults, interaction_id.clone())) - .await?; - ctx.has_sent_initial_response - .store(true, std::sync::atomic::Ordering::SeqCst); + create_interaction_response(M::create(defaults, modal_custom_id.clone())).await?; // Wait for user to submit - let response = serenity::ModalInteractionCollector::new(&ctx.discord.shard) - .filter(move |d| d.data.custom_id == interaction_id) - .await - .unwrap(); + let response = serenity::ModalInteractionCollector::new(&ctx.shard) + .filter(move |d| d.data.custom_id == modal_custom_id) + .timeout(timeout.unwrap_or(std::time::Duration::from_secs(3600))) + .await; + + let response = match response { + Some(x) => x, + None => return Ok(None), + }; // Send acknowledgement so that the pop-up is closed response - .create_response( - ctx.discord, - serenity::CreateInteractionResponse::Acknowledge, - ) + .create_response(ctx, serenity::CreateInteractionResponse::Acknowledge) .await?; - M::parse(response.data.clone()).map_err(serenity::Error::Other) + Ok(Some( + M::parse(response.data.clone()).map_err(serenity::Error::Other)?, + )) +} + +/// Convenience function for showing the modal and waiting for a response. +/// +/// If the user doesn't submit before the timeout expires, `None` is returned. +/// +/// Note: a modal must be the first response to a command. You cannot send any messages before, +/// or the modal will fail. +/// +/// This function: +/// 1. sends the modal via [`Modal::create()`] +/// 2. waits for the user to submit via [`serenity::ModalInteractionCollector`] +/// 3. acknowledges the submitted data so that Discord closes the pop-up for the user +/// 4. parses the submitted data via [`Modal::parse()`], wrapping errors in [`serenity::Error::Other`] +/// +/// If you need more specialized behavior, you can copy paste the implementation of this function +/// and adjust to your needs. The code of this function is just a starting point. +pub async fn execute_modal( + ctx: crate::ApplicationContext<'_, U, E>, + defaults: Option, + timeout: Option, +) -> Result, serenity::Error> { + let interaction = ctx.interaction.unwrap(); + let response = execute_modal_generic( + ctx.serenity_context, + |resp| interaction.create_response(ctx.http(), resp), + interaction.id.to_string(), + defaults, + timeout, + ) + .await?; + ctx.has_sent_initial_response + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(response) +} + +/// Convenience function for showing the modal on a message interaction and waiting for a response. +/// +/// If the user doesn't submit before the timeout expires, `None` is returned. +/// +/// This function: +/// 1. sends the modal via [`Modal::create()`] as a mci interaction response +/// 2. waits for the user to submit via [`serenity::ModalInteractionCollector`] +/// 3. acknowledges the submitted data so that Discord closes the pop-up for the user +/// 4. parses the submitted data via [`Modal::parse()`], wrapping errors in [`serenity::Error::Other`] +/// +/// If you need more specialized behavior, you can copy paste the implementation of this function +/// and adjust to your needs. The code of this function is just a starting point. +pub async fn execute_modal_on_component_interaction( + ctx: impl AsRef, + interaction: Arc, + defaults: Option, + timeout: Option, +) -> Result, serenity::Error> { + execute_modal_generic( + ctx.as_ref(), + |resp| interaction.create_response(ctx.as_ref(), resp), + interaction.id.to_string(), + defaults, + timeout, + ) + .await } /// Derivable trait for modal interactions, Discords version of interactive forms @@ -118,45 +186,22 @@ pub trait Modal: Sized { /// let users submit when all required fields are filled properly fn parse(data: serenity::ModalInteractionData) -> Result; - /// Convenience function for showing the modal and waiting for a response + /// Calls `execute_modal(ctx, None, None)`. See [`execute_modal`] /// - /// Note: a modal must be the first response to a command. You cannot send any messages before, - /// or the modal will fail - /// - /// This function: - /// 1. sends the modal via [`Self::create()`] - /// 2. waits for the user to submit via [`serenity::ModalInteractionCollector`] - /// 3. acknowledges the submitted data so that Discord closes the pop-up for the user - /// 4. parses the submitted data via [`Self::parse()`], wrapping errors in [`serenity::Error::Other`] + /// For a variant that is triggered on component interactions, see [`execute_modal_on_component_interaction`]. // TODO: add execute_with_defaults? Or add a `defaults: Option` param? async fn execute( ctx: crate::ApplicationContext<'_, U, E>, - ) -> Result { - execute(ctx, None::).await + ) -> Result, serenity::Error> { + execute_modal(ctx, None::, None).await } - /// Like [`Self::execute()`], but with a parameter to set default values for the fields. - /// - /// ```rust - /// # async fn _foo(ctx: poise::ApplicationContext<'_, (), ()>) -> Result<(), serenity::Error> { - /// # use poise::Modal as _; - /// #[derive(Default, poise::Modal)] - /// struct MyModal { - /// field_1: String, - /// field_2: String, - /// } - /// - /// # let ctx: poise::ApplicationContext<'static, (), ()> = todo!(); - /// MyModal::execute_with_defaults(ctx, MyModal { - /// field_1: "Default value".into(), - /// ..Default::default() - /// }).await?; - /// # Ok(()) } - /// ``` + /// Calls `execute_modal(ctx, Some(defaults), None)`. See [`execute_modal`] + // TODO: deprecate this in favor of execute_modal()? async fn execute_with_defaults( ctx: crate::ApplicationContext<'_, U, E>, defaults: Self, - ) -> Result { - execute(ctx, Some(defaults)).await + ) -> Result, serenity::Error> { + execute_modal(ctx, Some(defaults), None).await } } diff --git a/src/prefix_argument/argument_trait.rs b/src/prefix_argument/argument_trait.rs index 653dd3aa901f..d43f2959b2c5 100644 --- a/src/prefix_argument/argument_trait.rs +++ b/src/prefix_argument/argument_trait.rs @@ -67,7 +67,8 @@ where msg: &serenity::Message, ) -> Result<(&'a str, usize, T), (Box, Option)> { - let (args, string) = pop_string(args).map_err(|_| (TooFewArguments.into(), None))?; + let (args, string) = + pop_string(args).map_err(|_| (TooFewArguments::default().into(), None))?; let object = T::convert(ctx, msg.guild_id, Some(msg.channel_id), &string) .await .map_err(|e| (e.into(), Some(string)))?; @@ -100,12 +101,13 @@ impl<'a> PopArgumentHack<'a, bool> for &PhantomData { msg: &serenity::Message, ) -> Result<(&'a str, usize, bool), (Box, Option)> { - let (args, string) = pop_string(args).map_err(|_| (TooFewArguments.into(), None))?; + let (args, string) = + pop_string(args).map_err(|_| (TooFewArguments::default().into(), None))?; let value = match string.to_ascii_lowercase().trim() { "yes" | "y" | "true" | "t" | "1" | "enable" | "on" => true, "no" | "n" | "false" | "f" | "0" | "disable" | "off" => false, - _ => return Err((InvalidBool.into(), Some(string))), + _ => return Err((InvalidBool::default().into(), Some(string))), }; Ok((args.trim_start(), attachment_index, value)) @@ -127,7 +129,7 @@ impl<'a> PopArgumentHack<'a, serenity::Attachment> for &PhantomData) -> std::fmt::Result { f.write_str("couldn't find a valid code block") @@ -27,12 +30,14 @@ impl std::error::Error for CodeBlockError {} /// ``` /// /// Can be used as a command parameter. For more information, see [`Self::pop_from`]. -#[derive(Debug, PartialEq, Eq, Clone, Hash)] +#[derive(Default, Debug, PartialEq, Eq, Clone, Hash)] pub struct CodeBlock { /// The text inside the code block pub code: String, /// In multiline code blocks, the language code, if present pub language: Option, + #[doc(hidden)] + pub __non_exhaustive: (), } impl std::fmt::Display for CodeBlock { @@ -52,7 +57,7 @@ fn pop_from(args: &str) -> Result<(&str, CodeBlock), CodeBlockError> { let rest; let mut code_block = if let Some(code_block) = args.strip_prefix("```") { - let code_block_end = code_block.find("```").ok_or(CodeBlockError)?; + let code_block_end = code_block.find("```").ok_or_else(CodeBlockError::default)?; rest = &code_block[(code_block_end + 3)..]; let mut code_block = &code_block[..code_block_end]; @@ -77,23 +82,25 @@ fn pop_from(args: &str) -> Result<(&str, CodeBlock), CodeBlockError> { CodeBlock { code: code_block.to_owned(), language: language.map(|x| x.to_owned()), + __non_exhaustive: (), } } else if let Some(code_line) = args.strip_prefix('`') { - let code_line_end = code_line.find('`').ok_or(CodeBlockError)?; + let code_line_end = code_line.find('`').ok_or_else(CodeBlockError::default)?; rest = &code_line[(code_line_end + 1)..]; let code_line = &code_line[..code_line_end]; CodeBlock { code: code_line.to_owned(), language: None, + __non_exhaustive: (), } } else { - return Err(CodeBlockError); + return Err(CodeBlockError::default()); }; // Empty codeblocks like `` are not rendered as codeblocks by Discord if code_block.code.is_empty() { - Err(CodeBlockError) + Err(CodeBlockError::default()) } else { // discord likes to insert hair spaces at the end of code blocks sometimes for no reason code_block.code = code_block.code.trim_end_matches('\u{200a}').to_owned(); @@ -144,13 +151,14 @@ fn test_pop_code_block() { pop_from(string).unwrap().1, CodeBlock { code: code.into(), - language: language.map(|x| x.into()) + language: language.map(|x| x.into()), + __non_exhaustive: (), } ); } - assert_eq!(pop_from(""), Err(CodeBlockError)); - assert_eq!(pop_from("''"), Err(CodeBlockError)); - assert_eq!(pop_from("``"), Err(CodeBlockError)); - assert_eq!(pop_from("``````"), Err(CodeBlockError)); + assert!(pop_from("").is_err()); + assert!(pop_from("''").is_err()); + assert!(pop_from("``").is_err()); + assert!(pop_from("``````").is_err()); } diff --git a/src/prefix_argument/macros.rs b/src/prefix_argument/macros.rs index bd159b8bc626..858e4832ae88 100644 --- a/src/prefix_argument/macros.rs +++ b/src/prefix_argument/macros.rs @@ -119,7 +119,7 @@ macro_rules! _parse_prefix { ) => { let input = $args.trim_start(); if input.is_empty() { - $error = ($crate::TooFewArguments.into(), None); + $error = ($crate::TooFewArguments::default().into(), None); } else { match <$type as $crate::serenity_prelude::ArgumentConvert>::convert( $ctx, $msg.guild_id, Some($msg.channel_id), input @@ -223,7 +223,7 @@ macro_rules! parse_prefix_args { let attachment_index = $attachment_index; let mut error: (Box, Option) - = (Box::new($crate::TooManyArguments) as _, None); + = (Box::new($crate::TooManyArguments { __non_exhaustive: () }) as _, None); $crate::_parse_prefix!( ctx msg args attachment_index => [error] @@ -254,6 +254,21 @@ mod test { .await .unwrap(); let manager = client.shard_manager.clone(); + + let shard_id = serenity::ShardId(0); + let shard = serenity::Shard::new( + client.ws_url.clone(), + "example", + serenity::ShardInfo { + id: shard_id, + total: 1, + }, + serenity::GatewayIntents::empty(), + None, + ) + .await + .unwrap(); + drop(client); let shard_runner_opts = ::serenity::gateway::ShardRunnerOptions { @@ -262,18 +277,7 @@ mod test { raw_event_handlers: vec![], framework: None, manager, - shard: Shard::new( - Arc::new(Mutex::new("wss://gateway.discord.gg".to_string())), - "", - ShardInfo { - id: ShardId(0), - total: 1, - }, - Default::default(), - None, - ) - .await - .expect("failed to create shard"), + shard, #[cfg(feature = "cache")] cache: Default::default(), http: Arc::new(::serenity::http::Http::new("example")), @@ -284,7 +288,7 @@ mod test { let ctx = serenity::Context { data: Arc::new(tokio::sync::RwLock::new(::serenity::prelude::TypeMap::new())), shard: ::serenity::gateway::ShardMessenger::new(&shard_runner), - shard_id: ShardId(0), + shard_id, http: Arc::new(::serenity::http::Http::new("example")), #[cfg(feature = "cache")] cache: Default::default(), @@ -323,7 +327,8 @@ mod test { "yoo".into(), crate::CodeBlock { code: "that's cool".into(), - language: None + language: None, + __non_exhaustive: (), }, "!".into() ), diff --git a/src/prefix_argument/mod.rs b/src/prefix_argument/mod.rs index ef1d4532aeb5..fdfa6e534712 100644 --- a/src/prefix_argument/mod.rs +++ b/src/prefix_argument/mod.rs @@ -29,7 +29,7 @@ fn pop_string(args: &str) -> Result<(&str, String), crate::TooFewArguments> { let args = args.trim_start(); if args.is_empty() { - return Err(crate::TooFewArguments); + return Err(crate::TooFewArguments::default()); } let mut output = String::new(); @@ -60,8 +60,11 @@ fn pop_string(args: &str) -> Result<(&str, String), crate::TooFewArguments> { } /// Error thrown if user passes too many arguments to a command -#[derive(Debug)] -pub struct TooManyArguments; +#[derive(Default, Debug)] +pub struct TooManyArguments { + #[doc(hidden)] + pub __non_exhaustive: (), +} impl std::fmt::Display for TooManyArguments { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Too many arguments were passed") @@ -70,8 +73,11 @@ impl std::fmt::Display for TooManyArguments { impl std::error::Error for TooManyArguments {} /// Error thrown if user passes too few arguments to a command -#[derive(Debug)] -pub struct TooFewArguments; +#[derive(Default, Debug)] +pub struct TooFewArguments { + #[doc(hidden)] + pub __non_exhaustive: (), +} impl std::fmt::Display for TooFewArguments { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Too few arguments were passed") @@ -80,8 +86,11 @@ impl std::fmt::Display for TooFewArguments { impl std::error::Error for TooFewArguments {} /// Error thrown in prefix invocation when there's too few attachments -#[derive(Debug)] -pub struct MissingAttachment; +#[derive(Default, Debug)] +pub struct MissingAttachment { + #[doc(hidden)] + pub __non_exhaustive: (), +} impl std::fmt::Display for MissingAttachment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("A required attachment is missing") @@ -91,8 +100,11 @@ impl std::error::Error for MissingAttachment {} /// Error thrown when the user enters a string that is not recognized by a /// ChoiceParameter-derived enum -#[derive(Debug)] -pub struct InvalidChoice; +#[derive(Default, Debug)] +pub struct InvalidChoice { + #[doc(hidden)] + pub __non_exhaustive: (), +} impl std::fmt::Display for InvalidChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("You entered a non-existent choice") @@ -101,8 +113,11 @@ impl std::fmt::Display for InvalidChoice { impl std::error::Error for InvalidChoice {} /// Error thrown when the user enters a string that is not recognized as a boolean -#[derive(Debug)] -pub struct InvalidBool; +#[derive(Default, Debug)] +pub struct InvalidBool { + #[doc(hidden)] + pub __non_exhaustive: (), +} impl std::fmt::Display for InvalidBool { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Expected a string like `yes` or `no` for the boolean parameter") diff --git a/src/reply/builder.rs b/src/reply/builder.rs index 105ad45ad2d6..3ba6fe63dfe5 100644 --- a/src/reply/builder.rs +++ b/src/reply/builder.rs @@ -21,6 +21,8 @@ pub struct CreateReply { pub allowed_mentions: Option, /// Whether this message is an inline reply. pub reply: bool, + #[doc(hidden)] + pub __non_exhaustive: (), } impl CreateReply { @@ -115,6 +117,7 @@ impl CreateReply { ephemeral, allowed_mentions, reply: _, // can't reply to a message in interactions + __non_exhaustive: (), } = self; if let Some(content) = content { @@ -144,6 +147,7 @@ impl CreateReply { ephemeral, allowed_mentions, reply: _, + __non_exhaustive: (), } = self; if let Some(content) = content { @@ -173,6 +177,7 @@ impl CreateReply { ephemeral: _, // can't edit ephemerality in retrospect allowed_mentions, reply: _, + __non_exhaustive: (), } = self; if let Some(content) = content { @@ -203,6 +208,7 @@ impl CreateReply { ephemeral: _, // not supported in prefix allowed_mentions, reply: _, // can't edit reference message afterwards + __non_exhaustive: (), } = self; if let Some(content) = content { @@ -235,6 +241,7 @@ impl CreateReply { ephemeral: _, // not supported in prefix allowed_mentions, reply, + __non_exhaustive: (), } = self; if let Some(content) = content { diff --git a/src/reply/mod.rs b/src/reply/mod.rs index a9e6b53e7c18..353e889c0f6e 100644 --- a/src/reply/mod.rs +++ b/src/reply/mod.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; /// Private enum so we can extend, split apart, or merge variants without breaking changes #[derive(Clone)] -pub(super) enum ReplyHandleInner<'a> { +enum ReplyHandleInner<'a> { /// A reply sent to a prefix command, i.e. a normal standalone message Prefix(Box), /// An application command response @@ -36,7 +36,7 @@ pub(super) enum ReplyHandleInner<'a> { /// Discord sometimes returns the [`serenity::Message`] object directly, but sometimes you have to /// request it manually. This enum abstracts over the two cases #[derive(Clone)] -pub struct ReplyHandle<'a>(pub(super) ReplyHandleInner<'a>); +pub struct ReplyHandle<'a>(ReplyHandleInner<'a>); impl ReplyHandle<'_> { /// Retrieve the message object of the sent reply. @@ -103,7 +103,7 @@ impl ReplyHandle<'_> { match &self.0 { ReplyHandleInner::Prefix(msg) => { msg.clone() - .edit(ctx.discord(), reply.to_prefix_edit()) + .edit(ctx.serenity_context(), reply.to_prefix_edit()) .await?; } ReplyHandleInner::Application { @@ -132,19 +132,17 @@ impl ReplyHandle<'_> { /// Deletes this message pub async fn delete(&self, ctx: crate::Context<'_, U, E>) -> Result<(), serenity::Error> { match &self.0 { - ReplyHandleInner::Prefix(msg) => msg.delete(ctx.discord()).await?, + ReplyHandleInner::Prefix(msg) => msg.delete(ctx.serenity_context()).await?, ReplyHandleInner::Application { http: _, interaction, followup, } => match followup { Some(followup) => { - interaction - .delete_followup(ctx.discord(), followup.id) - .await?; + interaction.delete_followup(ctx, followup.id).await?; } None => { - interaction.delete_response(ctx.discord()).await?; + interaction.delete_response(ctx).await?; } }, ReplyHandleInner::Autocomplete => panic!("delete is a no-op in autocomplete context"), diff --git a/src/reply/send_reply.rs b/src/reply/send_reply.rs index 3f1c6fdcb304..b1c170e3db4d 100644 --- a/src/reply/send_reply.rs +++ b/src/reply/send_reply.rs @@ -27,7 +27,7 @@ pub async fn send_reply( builder: crate::CreateReply, ) -> Result, serenity::Error> { Ok(match ctx { - crate::Context::Prefix(ctx) => crate::ReplyHandle(super::ReplyHandleInner::Prefix( + crate::Context::Prefix(ctx) => super::ReplyHandle(super::ReplyHandleInner::Prefix( crate::send_prefix_reply(ctx, builder).await?, )), crate::Context::Application(ctx) => crate::send_application_reply(ctx, builder).await?, @@ -65,7 +65,7 @@ async fn _send_application_reply( let interaction = match ctx.interaction { crate::CommandOrAutocompleteInteraction::Command(x) => x, crate::CommandOrAutocompleteInteraction::Autocomplete(_) => { - return Ok(crate::ReplyHandle(super::ReplyHandleInner::Autocomplete)) + return Ok(super::ReplyHandle(super::ReplyHandleInner::Autocomplete)) } }; @@ -76,13 +76,13 @@ async fn _send_application_reply( let followup = if has_sent_initial_response { Some(Box::new( interaction - .create_followup(ctx.discord, data.to_slash_followup_response()) + .create_followup(ctx.serenity_context, data.to_slash_followup_response()) .await?, )) } else { interaction .create_response( - ctx.discord, + ctx.serenity_context, serenity::CreateInteractionResponse::Message(data.to_slash_initial_response()), ) .await?; @@ -92,8 +92,8 @@ async fn _send_application_reply( None }; - Ok(crate::ReplyHandle(crate::ReplyHandleInner::Application { - http: &ctx.discord.http, + Ok(super::ReplyHandle(super::ReplyHandleInner::Application { + http: &ctx.serenity_context.http, interaction, followup, })) @@ -115,25 +115,30 @@ async fn _send_prefix_reply<'a, U, E>( // This must only return None when we _actually_ want to reuse the existing response! There are // no checks later let lock_edit_tracker = || { - if ctx.command.reuse_response { - if let Some(edit_tracker) = &ctx.framework.options().prefix_options.edit_tracker { - return Some(edit_tracker.write().unwrap()); - } + if let Some(edit_tracker) = &ctx.framework.options().prefix_options.edit_tracker { + return Some(edit_tracker.write().unwrap()); } None }; - let existing_response = lock_edit_tracker() - .as_mut() - .and_then(|t| t.find_bot_response(ctx.msg.id)) - .cloned(); + let existing_response = if ctx.command.reuse_response { + lock_edit_tracker() + .as_mut() + .and_then(|t| t.find_bot_response(ctx.msg.id)) + .cloned() + } else { + None + }; Ok(Box::new(if let Some(mut response) = existing_response { - response.edit(ctx.discord, reply.to_prefix_edit()).await?; + response + .edit(ctx.serenity_context, reply.to_prefix_edit()) + .await?; // If the entry still exists after the await, update it to the new contents + // We don't check ctx.command.reuse_response because it's true anyways in this branch if let Some(mut edit_tracker) = lock_edit_tracker() { - edit_tracker.set_bot_response(ctx.msg, response.clone()); + edit_tracker.set_bot_response(ctx.msg, response.clone(), ctx.command.track_deletion); } response @@ -141,10 +146,12 @@ async fn _send_prefix_reply<'a, U, E>( let new_response = ctx .msg .channel_id - .send_message(ctx.discord, reply.to_prefix(ctx.msg)) + .send_message(ctx.serenity_context, reply.to_prefix(ctx.msg)) .await?; + // We don't check ctx.command.reuse_response because we need to store bot responses for + // track_deletion too if let Some(track_edits) = &mut lock_edit_tracker() { - track_edits.set_bot_response(ctx.msg, new_response.clone()); + track_edits.set_bot_response(ctx.msg, new_response.clone(), ctx.command.track_deletion); } new_response diff --git a/src/slash_argument/autocompletable.rs b/src/slash_argument/autocompletable.rs index 87a2ef0be845..ce2e74564892 100644 --- a/src/slash_argument/autocompletable.rs +++ b/src/slash_argument/autocompletable.rs @@ -9,10 +9,36 @@ use serenity::all as serenity; /// /// For more information, see the autocomplete.rs file in the `framework_usage` example pub struct AutocompleteChoice { - /// Name of the choice, displayed in the Discord UI - pub name: String, + /// Label of the choice, displayed in the Discord UI + pub label: String, /// Value of the choice, sent to the bot pub value: T, + #[doc(hidden)] + pub __non_exhaustive: (), +} + +impl AutocompleteChoice { + /// Creates a new autocomplete choice with the given text + pub fn new(value: T) -> AutocompleteChoice + where + T: ToString, + { + Self { + label: value.to_string(), + value, + __non_exhaustive: (), + } + } + + /// Like [`Self::new()`], but you can customize the JSON value sent to Discord as the unique + /// identifier of this autocomplete choice. + pub fn new_with_value(label: impl Into, value: T) -> Self { + Self { + label: label.into(), + value, + __non_exhaustive: (), + } + } } impl AutocompleteChoice { @@ -22,15 +48,16 @@ impl AutocompleteChoice { where T: Into, { - serenity::AutocompleteChoice::new(self.name, self.value) + serenity::AutocompleteChoice::new(self.label, self.value) } } impl From for AutocompleteChoice { fn from(value: T) -> Self { Self { - name: value.to_string(), + label: value.to_string(), value, + __non_exhaustive: (), } } } diff --git a/src/slash_argument/context_menu.rs b/src/slash_argument/context_menu.rs index bd94d1618ddc..0c2125ca32b0 100644 --- a/src/slash_argument/context_menu.rs +++ b/src/slash_argument/context_menu.rs @@ -1,4 +1,4 @@ -//! Contains a simple trai, implemented for all context meun command compatible parameter types +//! Contains a simple trait, implemented for all context menu command compatible parameter types use crate::serenity_prelude as serenity; use crate::BoxFuture; diff --git a/src/slash_argument/slash_macro.rs b/src/slash_argument/slash_macro.rs index 88ee3e9930e7..5f320f58515c 100644 --- a/src/slash_argument/slash_macro.rs +++ b/src/slash_argument/slash_macro.rs @@ -13,8 +13,13 @@ pub enum SlashArgError { /// /// Most often the result of the bot not having registered the command in Discord, so Discord /// stores an outdated version of the command and its parameters. - CommandStructureMismatch(&'static str), + #[non_exhaustive] + CommandStructureMismatch { + /// A short string describing what specifically is wrong/unexpected + description: &'static str, + }, /// A string parameter was found, but it could not be parsed into the target type. + #[non_exhaustive] Parse { /// Error that occured while parsing the string into the target type error: Box, @@ -28,7 +33,17 @@ pub enum SlashArgError { ), /// HTTP error occured while retrieving the model type from Discord Http(serenity::Error), + #[doc(hidden)] + __NonExhaustive, +} +/// Support functions for macro which can't create #[non_exhaustive] enum variants +#[doc(hidden)] +impl SlashArgError { + pub fn new_command_structure_mismatch(description: &'static str) -> Self { + Self::CommandStructureMismatch { description } + } } + impl SlashArgError { /// Converts this specialized slash argument error to a full FrameworkError /// @@ -38,24 +53,25 @@ impl SlashArgError { ctx: crate::ApplicationContext<'_, U, E>, ) -> crate::FrameworkError<'_, U, E> { match self { - crate::SlashArgError::CommandStructureMismatch(description) => { + Self::CommandStructureMismatch { description } => { crate::FrameworkError::CommandStructureMismatch { ctx, description } } - crate::SlashArgError::Parse { error, input } => crate::FrameworkError::ArgumentParse { + Self::Parse { error, input } => crate::FrameworkError::ArgumentParse { ctx: ctx.into(), error, input: Some(input), }, - crate::SlashArgError::Invalid(description) => crate::FrameworkError::ArgumentParse { + Self::Invalid(description) => crate::FrameworkError::ArgumentParse { ctx: ctx.into(), error: description.into(), input: None, }, - crate::SlashArgError::Http(error) => crate::FrameworkError::ArgumentParse { + Self::Http(error) => crate::FrameworkError::ArgumentParse { ctx: ctx.into(), error: error.into(), input: None, }, + Self::__NonExhaustive => unreachable!(), } } } @@ -67,11 +83,11 @@ impl From for SlashArgError { impl std::fmt::Display for SlashArgError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::CommandStructureMismatch(detail) => { + Self::CommandStructureMismatch { description } => { write!( f, "Bot author did not register their commands correctly ({})", - detail + description ) } Self::Parse { error, input } => { @@ -87,6 +103,7 @@ impl std::fmt::Display for SlashArgError { error ) } + Self::__NonExhaustive => unreachable!(), } } } @@ -94,9 +111,10 @@ impl std::error::Error for SlashArgError { fn cause(&self) -> Option<&dyn std::error::Error> { match self { Self::Parse { error, input: _ } => Some(&**error), - Self::CommandStructureMismatch(_) => None, + Self::CommandStructureMismatch { description: _ } => None, Self::Invalid(_) => None, Self::Http(error) => Some(error), + Self::__NonExhaustive => unreachable!(), } } } @@ -132,7 +150,7 @@ macro_rules! _parse_slash { // Extract T ($ctx:ident, $interaction:ident, $args:ident => $name:ident: $($type:tt)*) => { $crate::_parse_slash!($ctx, $interaction, $args => $name: Option<$($type)*>) - .ok_or($crate::SlashArgError::CommandStructureMismatch("a required argument is missing"))? + .ok_or($crate::SlashArgError::new_command_structure_mismatch("a required argument is missing"))? }; } diff --git a/src/slash_argument/slash_trait.rs b/src/slash_argument/slash_trait.rs index 5dd0129d9ff3..1908053672de 100644 --- a/src/slash_argument/slash_trait.rs +++ b/src/slash_argument/slash_trait.rs @@ -32,7 +32,9 @@ 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; + fn choices() -> Vec { + Vec::new() + } } /// Implemented for all types that can be used as a function parameter in a slash command. @@ -105,7 +107,11 @@ where ) -> Result { let string = match *value { serenity::ResolvedValue::String(s) => s, - _ => return Err(SlashArgError::CommandStructureMismatch("expected string")), + _ => { + return Err(SlashArgError::CommandStructureMismatch { + description: "expected string", + }) + } }; T::convert( ctx, @@ -129,9 +135,8 @@ where macro_rules! impl_for_integer { ($($t:ty)*) => { $( #[async_trait::async_trait] - impl SlashArgumentHack<$t> for &PhantomData<$t> { + impl SlashArgument for $t { async fn extract( - self, _: &impl serenity::CacheHttp, _: crate::CommandOrAutocompleteInteraction<'_>, value: &serenity::ResolvedValue<'_>, @@ -139,14 +144,16 @@ macro_rules! impl_for_integer { match *value { serenity::ResolvedValue::Integer(x) => x .try_into() - .map_err(|_| SlashArgError::CommandStructureMismatch( - "received out of bounds integer" - )), - _ => Err(SlashArgError::CommandStructureMismatch("expected integer")), + .map_err(|_| SlashArgError::CommandStructureMismatch { + description: "received out of bounds integer", + }), + _ => Err(SlashArgError::CommandStructureMismatch { + description: "expected integer", + }), } } - fn create(self, builder: serenity::CreateCommandOption) -> serenity::CreateCommandOption { + fn create(builder: serenity::CreateCommandOption) -> serenity::CreateCommandOption { builder .min_number_value(f64::max(<$t>::MIN as f64, -9007199254740991.)) .max_number_value(f64::min(<$t>::MAX as f64, 9007199254740991.)) @@ -181,23 +188,21 @@ impl SlashArgumentHack for &PhantomData { macro_rules! impl_slash_argument { ($type:ty, |$ctx:pat, $interaction:pat, $slash_param_type:ident ( $($arg:pat),* )| $extractor:expr) => { #[async_trait::async_trait] - impl SlashArgumentHack<$type> for &PhantomData<$type> { + impl SlashArgument for $type { async fn extract( - self, $ctx: &impl serenity::CacheHttp, $interaction: crate::CommandOrAutocompleteInteraction<'_>, value: &serenity::ResolvedValue<'_>, ) -> Result<$type, SlashArgError> { match *value { serenity::ResolvedValue::$slash_param_type( $($arg),* ) => Ok( $extractor ), - _ => Err(SlashArgError::CommandStructureMismatch( - concat!("expected ", stringify!($slash_param_type)) - )), + _ => Err(SlashArgError::CommandStructureMismatch { + description: concat!("expected ", stringify!($slash_param_type)) + }), } } fn create( - self, builder: serenity::CreateCommandOption, ) -> serenity::CreateCommandOption { builder.kind(serenity::CommandOptionType::$slash_param_type) diff --git a/src/structs/command.rs b/src/structs/command.rs index 4bc0099a4365..5f62b93680d1 100644 --- a/src/structs/command.rs +++ b/src/structs/command.rs @@ -30,6 +30,8 @@ pub struct Command { // ============= Command type agnostic data /// Subcommands of this command, if any pub subcommands: Vec>, + /// Require a subcommand to be invoked + pub subcommand_required: bool, /// Main name of the command. Aliases (prefix-only) can be set in [`Self::aliases`]. pub name: String, /// Localized names with locale string as the key (slash-only) @@ -44,20 +46,21 @@ pub struct Command { /// bots). If not explicitly configured, it falls back to the command function name. pub identifying_name: String, /// Identifier for the category that this command will be displayed in for help commands. - pub category: Option<&'static str>, + 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. - // TODO: rename to description pub description: Option, /// Localized descriptions with locale string as the key (slash-only) pub description_localizations: std::collections::HashMap, /// Multiline description with detailed usage instructions. Displayed in the command specific /// help: `~help command_name` - // TODO: fix the inconsistency that this is String and everywhere else it's &'static str - pub help_text: Option String>, + pub help_text: Option, /// Handles command cooldowns. Mainly for framework internal use - pub cooldowns: std::sync::Mutex, + pub cooldowns: std::sync::Mutex, + /// The [`CooldownConfig`](crate::CooldownConfig) that will be used + /// with the [`CooldownTracker`](crate::CooldownTracker) + pub cooldown_config: std::sync::Mutex, /// After the first response, whether to post subsequent responses as edits to the initial /// message /// @@ -105,15 +108,17 @@ pub struct Command { // ============= Prefix-specific data /// Alternative triggers for the command (prefix-only) - pub aliases: &'static [&'static str], + pub aliases: Vec, /// 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) + pub track_deletion: bool, /// Whether to broadcast a typing indicator while executing this commmand (prefix-only) pub broadcast_typing: bool, // ============= Application-specific data /// Context menu specific name for this command, displayed in Discord's context menu - pub context_menu_name: Option<&'static str>, + pub context_menu_name: Option, /// Whether responses to this command should be ephemeral by default (application-only) pub ephemeral: bool, @@ -221,10 +226,11 @@ impl Command { let context_menu_action = self.context_menu_action?; // TODO: localization? - let name = self.context_menu_name.unwrap_or(&self.name); + let name = self.context_menu_name.as_deref().unwrap_or(&self.name); let kind = match context_menu_action { crate::ContextMenuCommandAction::User(_) => serenity::CommandType::User, crate::ContextMenuCommandAction::Message(_) => serenity::CommandType::Message, + crate::ContextMenuCommandAction::__NonExhaustive => unreachable!(), }; Some( serenity::CreateCommand::new(name) @@ -235,7 +241,7 @@ impl Command { /// **Deprecated** #[deprecated = "Please use `poise::Command { category: \"...\", ..command() }` instead"] - pub fn category(mut self, category: &'static str) -> Self { + pub fn category(mut self, category: String) -> Self { self.category = Some(category); self } diff --git a/src/structs/context.rs b/src/structs/context.rs index a9aa589bfdae..9021db175559 100644 --- a/src/structs/context.rs +++ b/src/structs/context.rs @@ -22,6 +22,7 @@ pub enum Context<'a, U, E> { Application(crate::ApplicationContext<'a, U, E>), /// Prefix command context Prefix(crate::PrefixContext<'a, U, E>), + // Not non_exhaustive.. adding a whole new category of commands would justify breakage lol } impl Clone for Context<'_, U, E> { fn clone(&self) -> Self { @@ -39,7 +40,39 @@ impl<'a, U, E> From> for Context<'a, U, E> { Self::Prefix(x) } } -impl<'a, U, E> Context<'a, U, E> { +/// Macro to generate Context methods and also PrefixContext and ApplicationContext methods that +/// delegate to Context +macro_rules! context_methods { + ( $( + $( #[$($attrs:tt)*] )* + // pub $(async $($dummy:block)?)? fn $fn_name:ident $() + // $fn_name:ident ($($sig:tt)*) $body:block + $($await:ident)? ( $fn_name:ident $self:ident $($arg:ident)* ) + ( $($sig:tt)* ) $body:block + )* ) => { + impl<'a, U, E> Context<'a, U, E> { $( + $( #[$($attrs)*] )* + $($sig)* $body + )* } + + impl<'a, U, E> crate::PrefixContext<'a, U, E> { $( + $( #[$($attrs)*] )* + $($sig)* { + $crate::Context::Prefix($self).$fn_name($($arg)*) $(.$await)? + } + )* } + + impl<'a, U, E> crate::ApplicationContext<'a, U, E> { $( + $( #[$($attrs)*] )* + $($sig)* { + $crate::Context::Application($self).$fn_name($($arg)*) $(.$await)? + } + )* } + }; +} +// Note how you have to surround the function signature in parantheses, and also add a line before +// the signature with the function name, parameter names and maybe `await` token +context_methods! { /// Defer the response, giving the bot multiple minutes to respond without the user seeing an /// "interaction failed error". /// @@ -49,7 +82,8 @@ impl<'a, U, E> Context<'a, U, E> { /// No-op if this is an autocomplete context /// /// This will make the response public; to make it ephemeral, use [`Self::defer_ephemeral()`]. - pub async fn defer(self) -> Result<(), serenity::Error> { + await (defer self) + (pub async fn defer(self) -> Result<(), serenity::Error>) { if let Self::Application(ctx) = self { ctx.defer_response(false).await?; } @@ -59,7 +93,8 @@ impl<'a, U, E> Context<'a, U, E> { /// See [`Self::defer()`] /// /// This will make the response ephemeral; to make it public, use [`Self::defer()`]. - pub async fn defer_ephemeral(self) -> Result<(), serenity::Error> { + await (defer_ephemeral self) + (pub async fn defer_ephemeral(self) -> Result<(), serenity::Error>) { if let Self::Application(ctx) = self { ctx.defer_response(true).await?; } @@ -71,42 +106,88 @@ impl<'a, U, E> Context<'a, U, E> { /// If this is a prefix command, a typing broadcast is started until the return value is /// dropped. // #[must_use = "The typing broadcast will only persist if you store it"] // currently doesn't work - pub async fn defer_or_broadcast(self) -> Result, serenity::Error> { + await (defer_or_broadcast self) + (pub async fn defer_or_broadcast(self) -> Result, serenity::Error>) { Ok(match self { Self::Application(ctx) => { ctx.defer_response(false).await?; None } - Self::Prefix(ctx) => Some(ctx.msg.channel_id.start_typing(&ctx.discord.http)), + Self::Prefix(ctx) => Some( + ctx.msg + .channel_id + .start_typing(&ctx.serenity_context.http), + ), }) } /// Shorthand of [`crate::say_reply`] - pub async fn say( + + await (say self text) + (pub async fn say( self, text: impl Into, - ) -> Result, serenity::Error> { + ) -> Result, serenity::Error>) { crate::say_reply(self, text).await } + /// Like [`Self::say`], but formats the message as a reply to the user's command + /// message. + /// + /// Equivalent to `.send(|b| b.content("...").reply(true))`. + /// + /// Only has an effect in prefix context, because slash command responses are always + /// formatted as a reply. + /// + /// Note: panics when called in an autocomplete context! + await (reply self text) + (pub async fn reply( + self, + text: impl Into, + ) -> Result, serenity::Error>) { + self.send(crate::CreateReply::new().content(text).reply(true)).await + } + /// Shorthand of [`crate::send_reply`] - pub async fn send<'att>( + + await (send self builder) + (pub async fn send<'att>( self, builder: crate::CreateReply, - ) -> Result, serenity::Error> { + ) -> Result, serenity::Error>) { crate::send_reply(self, builder).await } /// Return the stored [`serenity::Context`] within the underlying context type. - pub fn discord(&self) -> &'a serenity::Context { + (serenity_context self) + (pub fn serenity_context(self) -> &'a serenity::Context) { match self { - Self::Application(ctx) => ctx.discord, - Self::Prefix(ctx) => ctx.discord, + Self::Application(ctx) => ctx.serenity_context, + Self::Prefix(ctx) => ctx.serenity_context, + } + } + + /// Create a [`crate::CooldownContext`] based off the underlying context type. + (cooldown_context self) + (pub fn cooldown_context(self) -> crate::CooldownContext) { + crate::CooldownContext { + user_id: self.author().id, + channel_id: self.channel_id(), + guild_id: self.guild_id() } } + /// See [`Self::serenity_context`]. + #[deprecated = "poise::Context can now be passed directly into most serenity functions. Otherwise, use `.serenity_context()` now"] + #[allow(deprecated)] + (discord self) + (pub fn discord(self) -> &'a serenity::Context) { + self.serenity_context() + } + /// Returns a view into data stored by the framework, like configuration - pub fn framework(&self) -> crate::FrameworkContext<'a, U, E> { + (framework self) + (pub fn framework(self) -> crate::FrameworkContext<'a, U, E>) { match self { Self::Application(ctx) => ctx.framework, Self::Prefix(ctx) => ctx.framework, @@ -114,7 +195,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Return a reference to your custom user data - pub fn data(&self) -> &'a U { + (data self) + (pub fn data(self) -> &'a U) { match self { Self::Application(ctx) => ctx.data, Self::Prefix(ctx) => ctx.data, @@ -122,7 +204,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Return the channel ID of this context - pub fn channel_id(&self) -> serenity::ChannelId { + (channel_id self) + (pub fn channel_id(self) -> serenity::ChannelId) { match self { Self::Application(ctx) => ctx.interaction.channel_id(), Self::Prefix(ctx) => ctx.msg.channel_id, @@ -130,7 +213,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Returns the guild ID of this context, if we are inside a guild - pub fn guild_id(&self) -> Option { + (guild_id self) + (pub fn guild_id(self) -> Option) { match self { Self::Application(ctx) => ctx.interaction.guild_id(), Self::Prefix(ctx) => ctx.msg.guild_id, @@ -140,8 +224,9 @@ impl<'a, U, E> Context<'a, U, E> { // Doesn't fit in with the rest of the functions here but it's convenient /// Return the guild of this context, if we are inside a guild. #[cfg(feature = "cache")] - pub fn guild(&self) -> Option> { - self.guild_id()?.to_guild_cached(self.discord()) + (guild self) + (pub fn guild(self) -> Option>) { + self.guild_id()?.to_guild_cached(self.serenity_context()) } // Doesn't fit in with the rest of the functions here but it's convenient @@ -151,13 +236,14 @@ impl<'a, U, E> Context<'a, U, E> { /// an HTTP request /// /// Returns None if in DMs, or if the guild HTTP request fails - pub async fn partial_guild(&self) -> Option { + await (partial_guild self) + (pub async fn partial_guild(self) -> Option) { #[cfg(feature = "cache")] - if let Some(guild) = self.guild_id()?.to_guild_cached(self.discord()) { + if let Some(guild) = self.guild_id()?.to_guild_cached(&self) { return Some(guild.clone().into()); } - self.guild_id()?.to_partial_guild(self.discord()).await.ok() + self.guild_id()?.to_partial_guild(self.serenity_context()).await.ok() } // Doesn't fit in with the rest of the functions here but it's convenient @@ -170,12 +256,13 @@ impl<'a, U, E> Context<'a, U, E> { /// request failed /// /// Warning: can clone the entire Member instance out of the cache - pub async fn author_member(&'a self) -> Option> { + await (author_member self) + (pub async fn author_member(self) -> Option>) { if let Self::Application(ctx) = self { ctx.interaction.member().map(Cow::Borrowed) } else { self.guild_id()? - .member(self.discord(), self.author().id) + .member(self.serenity_context(), self.author().id) .await .ok() .map(Cow::Owned) @@ -183,7 +270,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Return the datetime of the invoking message or interaction - pub fn created_at(&self) -> serenity::Timestamp { + (created_at self) + (pub fn created_at(self) -> serenity::Timestamp) { match self { Self::Application(ctx) => ctx.interaction.id().created_at(), Self::Prefix(ctx) => ctx.msg.timestamp, @@ -191,7 +279,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Get the author of the command message or application command. - pub fn author(&self) -> &'a serenity::User { + (author self) + (pub fn author(self) -> &'a serenity::User) { match self { Self::Application(ctx) => ctx.interaction.user(), Self::Prefix(ctx) => &ctx.msg.author, @@ -199,7 +288,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Return a ID that uniquely identifies this command invocation. - pub fn id(&self) -> u64 { + (id self) + (pub fn id(self) -> u64) { match self { Self::Application(ctx) => ctx.interaction.id().get(), Self::Prefix(ctx) => { @@ -229,7 +319,8 @@ impl<'a, U, E> Context<'a, U, E> { /// If the invoked command was a subcommand, these are the parent commands, ordered top-level /// downwards. - pub fn parent_commands(&self) -> &'a [&'a crate::Command] { + (parent_commands self) + (pub fn parent_commands(self) -> &'a [&'a crate::Command]) { match self { Self::Prefix(x) => x.parent_commands, Self::Application(x) => x.parent_commands, @@ -237,7 +328,8 @@ impl<'a, U, E> Context<'a, U, E> { } /// Returns a reference to the command. - pub fn command(&self) -> &'a crate::Command { + (command self) + (pub fn command(self) -> &'a crate::Command) { match self { Self::Prefix(x) => x.command, Self::Application(x) => x.command, @@ -246,7 +338,8 @@ impl<'a, U, E> Context<'a, U, E> { /// Returns the prefix this command was invoked with, or a slash (`/`), if this is an /// application command. - pub fn prefix(&self) -> &'a str { + (prefix self) + (pub fn prefix(self) -> &'a str) { match self { Context::Prefix(ctx) => ctx.prefix, Context::Application(_) => "/", @@ -259,72 +352,20 @@ impl<'a, U, E> Context<'a, U, E> { /// /// In slash contexts, the given command name will always be returned verbatim, since there are /// no slash command aliases and the user has no control over spelling - pub fn invoked_command_name(&self) -> &'a str { + (invoked_command_name self) + (pub fn invoked_command_name(self) -> &'a str) { match self { Self::Prefix(ctx) => ctx.invoked_command_name, Self::Application(ctx) => &ctx.interaction.data().name, } } - /// Actual implementation of rerun() that returns `FrameworkError` for implementation convenience - async fn rerun_inner(self) -> Result<(), crate::FrameworkError<'a, U, E>> { - match self { - Self::Application(ctx) => { - // Skip autocomplete interactions - let interaction = match ctx.interaction { - crate::CommandOrAutocompleteInteraction::Command(interaction) => interaction, - crate::CommandOrAutocompleteInteraction::Autocomplete(_) => return Ok(()), - }; - - // Check slash command - if interaction.data.kind == serenity::CommandType::ChatInput { - return if let Some(action) = ctx.command.slash_action { - action(ctx).await - } else { - Ok(()) - }; - } - - // Check context menu command - if let (Some(action), Some(target)) = - (ctx.command.context_menu_action, interaction.data.target()) - { - return match action { - crate::ContextMenuCommandAction::User(action) => { - if let serenity::ResolvedTarget::User(user, _) = target { - action(ctx, user.clone()).await - } else { - Ok(()) - } - } - crate::ContextMenuCommandAction::Message(action) => { - if let serenity::ResolvedTarget::Message(message) = target { - action(ctx, message.clone()).await - } else { - Ok(()) - } - } - }; - } - } - Self::Prefix(ctx) => { - if let Some(action) = ctx.command.prefix_action { - return action(ctx).await; - } - } - } - - // Fallback if the Command doesn't have the action it needs to execute this context - // (This should never happen, because if this context cannot be executed, how could this - // method have been called) - Ok(()) - } - /// Re-runs this entire command invocation /// /// Permission checks are omitted; the command code is directly executed as a function. The /// result is returned by this function - pub async fn rerun(self) -> Result<(), E> { + await (rerun self) + (pub async fn rerun(self) -> Result<(), E>) { match self.rerun_inner().await { Ok(()) => Ok(()), Err(crate::FrameworkError::Command { error, ctx: _ }) => Err(error), @@ -340,7 +381,8 @@ impl<'a, U, E> Context<'a, U, E> { /// Returns the string with which this command was invoked. /// /// For example `"/slash_command subcommand arg1:value1 arg2:value2"`. - pub fn invocation_string(&self) -> String { + (invocation_string self) + (pub fn invocation_string(self) -> String) { match self { Context::Application(ctx) => { let mut string = String::from("/"); @@ -391,17 +433,6 @@ impl<'a, U, E> Context<'a, U, E> { Ok(()) } }; - // if let Some(x) = arg.value.as_bool() { - // let _ = write!(string, "{}", x); - // } else if let Some(x) = arg.value.as_i64() { - // let _ = write!(string, "{}", x); - // } else if let Some(x) = arg.value.as_u64() { - // let _ = write!(string, "{}", x); - // } else if let Some(x) = arg.value.as_f64() { - // let _ = write!(string, "{}", x); - // } else if let Some(x) = arg.value.as_str() { - // let _ = write!(string, "{}", x); - // } } string } @@ -409,29 +440,23 @@ impl<'a, U, E> Context<'a, U, E> { } } - /// Returns the raw type erased invocation data - fn invocation_data_raw(&self) -> &tokio::sync::Mutex> { - match self { - Context::Application(ctx) => ctx.invocation_data, - Context::Prefix(ctx) => ctx.invocation_data, - } - } - /// Stores the given value as the data for this command invocation /// /// This data is carried across the `pre_command` hook, checks, main command execution, and /// `post_command`. It may be useful to cache data or pass information to later phases of command /// execution. - pub async fn set_invocation_data(&self, data: T) { + await (set_invocation_data self data) + (pub async fn set_invocation_data(self, data: T)) { *self.invocation_data_raw().lock().await = Box::new(data); } /// Attempts to get the invocation data with the requested type /// /// If the stored invocation data has a different type than requested, None is returned - pub async fn invocation_data( - &self, - ) -> Option + '_> { + await (invocation_data self) + (pub async fn invocation_data( + self, + ) -> Option + 'a>) { tokio::sync::MutexGuard::try_map(self.invocation_data_raw().lock().await, |any| { any.downcast_mut() }) @@ -439,16 +464,130 @@ impl<'a, U, E> Context<'a, U, E> { } /// If available, returns the locale (selected language) of the invoking user - pub fn locale(&self) -> Option<&str> { + (locale self) + (pub fn locale(self) -> Option<&'a str>) { match self { Context::Application(ctx) => Some(ctx.interaction.locale()), Context::Prefix(_) => None, } } - /// Creates a [`serenity::CacheHttp`] from the serenity Context - pub fn cache_and_http(&self) -> impl serenity::CacheHttp + 'a { - self.discord() + /// Returns serenity's cache which stores various useful data received from the gateway + /// + /// Shorthand for [`.serenity_context().cache`](serenity::Context::cache) + #[cfg(feature = "cache")] + (cache self) + (pub fn cache(self) -> &'a serenity::Cache) { + &self.serenity_context().cache + } + + /// Returns serenity's raw Discord API client to make raw API requests, if needed. + /// + /// Shorthand for [`.serenity_context().http`](serenity::Context::http) + (http self) + (pub fn http(self) -> &'a serenity::Http) { + &self.serenity_context().http + } +} + +impl<'a, U, E> Context<'a, U, E> { + /// Actual implementation of rerun() that returns `FrameworkError` for implementation convenience + async fn rerun_inner(self) -> Result<(), crate::FrameworkError<'a, U, E>> { + match self { + Self::Application(ctx) => { + // Skip autocomplete interactions + let interaction = match ctx.interaction { + crate::CommandOrAutocompleteInteraction::Command(interaction) => interaction, + crate::CommandOrAutocompleteInteraction::Autocomplete(_) => return Ok(()), + }; + + // Check slash command + if interaction.data.kind == serenity::CommandType::ChatInput { + return if let Some(action) = ctx.command.slash_action { + action(ctx).await + } else { + Ok(()) + }; + } + + // Check context menu command + if let (Some(action), Some(target)) = + (ctx.command.context_menu_action, &interaction.data.target()) + { + return match action { + crate::ContextMenuCommandAction::User(action) => { + if let serenity::ResolvedTarget::User(user, _) = target { + action(ctx, (*user).clone()).await + } else { + Ok(()) + } + } + crate::ContextMenuCommandAction::Message(action) => { + if let serenity::ResolvedTarget::Message(message) = target { + action(ctx, (*message).clone()).await + } else { + Ok(()) + } + } + crate::ContextMenuCommandAction::__NonExhaustive => unreachable!(), + }; + } + } + Self::Prefix(ctx) => { + if let Some(action) = ctx.command.prefix_action { + return action(ctx).await; + } + } + } + + // Fallback if the Command doesn't have the action it needs to execute this context + // (This should never happen, because if this context cannot be executed, how could this + // method have been called) + Ok(()) + } + + /// Returns the raw type erased invocation data + fn invocation_data_raw(self) -> &'a tokio::sync::Mutex> { + match self { + Context::Application(ctx) => ctx.invocation_data, + Context::Prefix(ctx) => ctx.invocation_data, + } + } +} + +// Forwards for serenity::Context's impls. With these, poise::Context can be passed in as-is to +// serenity API functions. +#[cfg(feature = "cache")] +impl AsRef for Context<'_, U, E> { + fn as_ref(&self) -> &serenity::Cache { + &self.serenity_context().cache + } +} +impl AsRef for Context<'_, U, E> { + fn as_ref(&self) -> &serenity::Http { + &self.serenity_context().http + } +} +impl AsRef for Context<'_, U, E> { + fn as_ref(&self) -> &serenity::ShardMessenger { + &self.serenity_context().shard + } +} +// Originally added as part of component interaction modals; not sure if this impl is really +// required by anything else... It makes sense to have though imo +impl AsRef for Context<'_, U, E> { + fn as_ref(&self) -> &serenity::Context { + self.serenity_context() + } +} +impl serenity::CacheHttp for Context<'_, U, E> { + fn http(&self) -> &serenity::Http { + &self.serenity_context().http + } + + #[cfg(feature = "cache")] + fn cache(&self) -> Option<&std::sync::Arc> { + Some(&self.serenity_context().cache) } } @@ -461,12 +600,14 @@ pub struct PartialContext<'a, U, E> { /// ID of the invocation author pub author: &'a serenity::User, /// Serenity's context, like HTTP or cache - pub discord: &'a serenity::Context, + 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: (), } impl Copy for PartialContext<'_, U, E> {} @@ -482,36 +623,10 @@ impl<'a, U, E> From> for PartialContext<'a, U, E> { guild_id: ctx.guild_id(), channel_id: ctx.channel_id(), author: ctx.author(), - discord: ctx.discord(), + serenity_context: ctx.serenity_context(), framework: ctx.framework(), data: ctx.data(), + __non_exhaustive: (), } } } - -impl<'a, U, E> AsRef for Context<'a, U, E> { - fn as_ref(&self) -> &serenity::Http { - &self.discord().http - } -} - -#[cfg(feature = "cache")] -impl<'a, U, E> AsRef for Context<'a, U, E> { - fn as_ref(&self) -> &serenity::Cache { - &self.discord().cache - } -} - -impl<'a, U, E> serenity::CacheHttp for Context<'a, U, E> -where - U: Sync, -{ - fn http(&self) -> &serenity::Http { - &self.discord().http - } - - #[cfg(feature = "cache")] - fn cache(&self) -> Option<&std::sync::Arc> { - Some(&self.discord().cache) - } -} diff --git a/src/structs/framework_error.rs b/src/structs/framework_error.rs index cecdb77b54e5..0717138fd37a 100644 --- a/src/structs/framework_error.rs +++ b/src/structs/framework_error.rs @@ -10,6 +10,7 @@ use crate::serenity_prelude as serenity; #[derivative(Debug)] pub enum FrameworkError<'a, U, E> { /// User code threw an error in user data setup + #[non_exhaustive] Setup { /// Error which was thrown in the setup code error: E, @@ -22,9 +23,10 @@ pub enum FrameworkError<'a, U, E> { #[derivative(Debug = "ignore")] ctx: &'a serenity::Context, }, - /// User code threw an error in generic event listener - Listener { - /// Error which was thrown in the listener code + /// User code threw an error in generic event event handler + #[non_exhaustive] + EventHandler { + /// Error which was thrown in the event handler code error: E, /// Which event was being processed when the error occurred event: &'a serenity::FullEvent, @@ -33,13 +35,38 @@ pub enum FrameworkError<'a, U, E> { framework: crate::FrameworkContext<'a, U, E>, }, /// Error occured during command execution + #[non_exhaustive] Command { /// Error which was thrown in the command code error: E, /// General context ctx: crate::Context<'a, U, E>, }, + /// Command was invoked without specifying a subcommand, but the command has `subcommand_required` set + SubcommandRequired { + /// General context + ctx: crate::Context<'a, U, E>, + }, + /// Panic occured at any phase of command execution after constructing the `crate::Context`. + /// + /// This feature is intended as a last-resort safeguard to gracefully print an error message to + /// the user on a panic. Panics should only be thrown for bugs in the code, don't use this for + /// normal errors! + #[non_exhaustive] + CommandPanic { + /// Panic payload which was thrown in the command code + /// + /// If a panic was thrown via [`std::panic::panic_any()`] and the payload was neither &str, + /// nor String, the payload is `None`. + /// + /// The reason the original [`Box`] payload isn't provided here is that it + /// would make [`FrameworkError`] not [`Sync`] anymore. + payload: Option, + /// Command context + ctx: crate::Context<'a, U, E>, + }, /// A command argument failed to parse from the Discord message or interaction content + #[non_exhaustive] ArgumentParse { /// Error which was thrown by the parameter type's parsing routine error: Box, @@ -53,6 +80,7 @@ pub enum FrameworkError<'a, U, E> { /// /// Most often the result of the bot not having registered the command in Discord, so Discord /// stores an outdated version of the command and its parameters. + #[non_exhaustive] CommandStructureMismatch { /// Developer-readable description of the type mismatch description: &'static str, @@ -60,6 +88,7 @@ pub enum FrameworkError<'a, U, E> { ctx: crate::ApplicationContext<'a, U, E>, }, /// Command was invoked before its cooldown expired + #[non_exhaustive] CooldownHit { /// Time until the command may be invoked for the next time in the given context remaining_cooldown: std::time::Duration, @@ -67,7 +96,8 @@ pub enum FrameworkError<'a, U, E> { ctx: crate::Context<'a, U, E>, }, /// Command was invoked but the bot is lacking the permissions specified in - /// [`crate::Command::required_bot_permissions`] + /// [`crate::Command::required_permissions`] + #[non_exhaustive] MissingBotPermissions { /// Which permissions in particular the bot is lacking for this command missing_permissions: serenity::Permissions, @@ -76,6 +106,7 @@ pub enum FrameworkError<'a, U, E> { }, /// Command was invoked but the user is lacking the permissions specified in /// [`crate::Command::required_bot_permissions`] + #[non_exhaustive] MissingUserPermissions { /// List of permissions that the user is lacking. May be None if retrieving the user's /// permissions failed @@ -84,26 +115,31 @@ pub enum FrameworkError<'a, U, E> { ctx: crate::Context<'a, U, E>, }, /// A non-owner tried to invoke an owners-only command + #[non_exhaustive] NotAnOwner { /// General context ctx: crate::Context<'a, U, E>, }, /// Command was invoked but the channel was a DM channel + #[non_exhaustive] GuildOnly { /// General context ctx: crate::Context<'a, U, E>, }, /// Command was invoked but the channel was a non-DM channel + #[non_exhaustive] DmOnly { /// General context ctx: crate::Context<'a, U, E>, }, /// Command was invoked but the channel wasn't a NSFW channel + #[non_exhaustive] NsfwOnly { /// General context ctx: crate::Context<'a, U, E>, }, /// Provided pre-command check either errored, or returned false, so command execution aborted + #[non_exhaustive] CommandCheckFailed { /// If execution wasn't aborted because of an error but because it successfully returned /// false, this field is None @@ -113,6 +149,7 @@ pub enum FrameworkError<'a, U, E> { }, /// [`crate::PrefixFrameworkOptions::dynamic_prefix`] or /// [`crate::PrefixFrameworkOptions::stripped_dynamic_prefix`] returned an error + #[non_exhaustive] DynamicPrefix { /// Error which was thrown in the dynamic prefix code error: E, @@ -123,6 +160,7 @@ pub enum FrameworkError<'a, U, E> { msg: &'a serenity::Message, }, /// A message had the correct prefix but the following string was not a recognized command + #[non_exhaustive] UnknownCommand { /// Serenity's Context #[derivative(Debug = "ignore")] @@ -145,6 +183,7 @@ pub enum FrameworkError<'a, U, E> { trigger: crate::MessageDispatchTrigger, }, /// The command name from the interaction is unrecognized + #[non_exhaustive] UnknownInteraction { #[derivative(Debug = "ignore")] /// Serenity's Context @@ -157,31 +196,33 @@ pub enum FrameworkError<'a, U, E> { }, // #[non_exhaustive] forbids struct update syntax for ?? reason #[doc(hidden)] - __NonExhaustive, + __NonExhaustive(std::convert::Infallible), } impl<'a, U, E> FrameworkError<'a, U, E> { - /// Returns the [`serenity::Context`] of this error, except on [`Self::Listener`] (not all + /// Returns the [`serenity::Context`] of this error, except on [`Self::EventHandler`] (not all /// event types have a Context available). - pub fn discord(&self) -> Option<&'a serenity::Context> { + pub fn serenity_context(&self) -> Option<&'a serenity::Context> { Some(match *self { Self::Setup { ctx, .. } => ctx, - Self::Listener { .. } => return None, - Self::Command { ctx, .. } => ctx.discord(), - Self::ArgumentParse { ctx, .. } => ctx.discord(), - Self::CommandStructureMismatch { ctx, .. } => ctx.discord, - Self::CooldownHit { ctx, .. } => ctx.discord(), - Self::MissingBotPermissions { ctx, .. } => ctx.discord(), - Self::MissingUserPermissions { ctx, .. } => ctx.discord(), - Self::NotAnOwner { ctx, .. } => ctx.discord(), - Self::GuildOnly { ctx, .. } => ctx.discord(), - Self::DmOnly { ctx, .. } => ctx.discord(), - Self::NsfwOnly { ctx, .. } => ctx.discord(), - Self::CommandCheckFailed { ctx, .. } => ctx.discord(), - Self::DynamicPrefix { ctx, .. } => ctx.discord, + Self::EventHandler { .. } => return None, + 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::CooldownHit { ctx, .. } => ctx.serenity_context(), + Self::MissingBotPermissions { ctx, .. } => ctx.serenity_context(), + Self::MissingUserPermissions { 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::__NonExhaustive => unreachable!(), + Self::__NonExhaustive(unreachable) => match unreachable {}, }) } @@ -189,6 +230,8 @@ impl<'a, U, E> FrameworkError<'a, U, E> { pub fn ctx(&self) -> Option> { Some(match *self { Self::Command { ctx, .. } => ctx, + Self::SubcommandRequired { ctx } => ctx, + Self::CommandPanic { ctx, .. } => ctx, Self::ArgumentParse { ctx, .. } => ctx, Self::CommandStructureMismatch { ctx, .. } => crate::Context::Application(ctx), Self::CooldownHit { ctx, .. } => ctx, @@ -200,11 +243,11 @@ impl<'a, U, E> FrameworkError<'a, U, E> { Self::NsfwOnly { ctx, .. } => ctx, Self::CommandCheckFailed { ctx, .. } => ctx, Self::Setup { .. } - | Self::Listener { .. } + | Self::EventHandler { .. } | Self::UnknownCommand { .. } | Self::UnknownInteraction { .. } | Self::DynamicPrefix { .. } => return None, - Self::__NonExhaustive => unreachable!(), + Self::__NonExhaustive(unreachable) => match unreachable {}, }) } @@ -218,6 +261,29 @@ impl<'a, U, E> FrameworkError<'a, U, E> { } } +/// Support functions for the macro, which can't create these #[non_exhaustive] enum variants +#[doc(hidden)] +impl<'a, U, E> FrameworkError<'a, U, E> { + pub fn new_command(ctx: crate::Context<'a, U, E>, error: E) -> Self { + Self::Command { error, ctx } + } + + pub fn new_argument_parse( + ctx: crate::Context<'a, U, E>, + input: Option, + error: Box, + ) -> Self { + Self::ArgumentParse { error, input, ctx } + } + + pub fn new_command_structure_mismatch( + ctx: crate::ApplicationContext<'a, U, E>, + description: &'static str, + ) -> Self { + Self::CommandStructureMismatch { description, ctx } + } +} + /// Simple macro to deduplicate code. Can't be a function due to lifetime issues with `format_args` macro_rules! full_command_name { ($ctx:expr) => { @@ -234,14 +300,28 @@ impl std::fmt::Display for FrameworkError<'_, U, E> { data_about_bot: _, ctx: _, } => write!(f, "poise setup error"), - Self::Listener { + Self::EventHandler { error: _, event, framework: _, - } => write!(f, "error in {} event listener", event.snake_case_name()), + } => write!( + f, + "error in {} event event handler", + event.snake_case_name() + ), Self::Command { error: _, ctx } => { write!(f, "error in command `{}`", full_command_name!(ctx)) } + Self::SubcommandRequired { ctx } => { + write!( + f, + "expected subcommand for command `{}`", + full_command_name!(ctx) + ) + } + Self::CommandPanic { ctx, payload: _ } => { + write!(f, "panic in command `{}`", full_command_name!(ctx)) + } Self::ArgumentParse { error: _, input, @@ -327,7 +407,7 @@ impl std::fmt::Display for FrameworkError<'_, U, E> { Self::UnknownInteraction { interaction, .. } => { write!(f, "unknown interaction `{}`", interaction.data().name) } - Self::__NonExhaustive => unreachable!(), + Self::__NonExhaustive(unreachable) => match *unreachable {}, } } } @@ -338,8 +418,10 @@ impl<'a, U: std::fmt::Debug, E: std::error::Error + 'static> std::error::Error fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Setup { error, .. } => Some(error), - Self::Listener { error, .. } => Some(error), + Self::EventHandler { error, .. } => Some(error), Self::Command { error, .. } => Some(error), + Self::SubcommandRequired { .. } => None, + Self::CommandPanic { .. } => None, Self::ArgumentParse { error, .. } => Some(&**error), Self::CommandStructureMismatch { .. } => None, Self::CooldownHit { .. } => None, @@ -353,7 +435,7 @@ impl<'a, U: std::fmt::Debug, E: std::error::Error + 'static> std::error::Error Self::DynamicPrefix { error, .. } => Some(error), Self::UnknownCommand { .. } => None, Self::UnknownInteraction { .. } => None, - Self::__NonExhaustive => unreachable!(), + Self::__NonExhaustive(unreachable) => match *unreachable {}, } } } diff --git a/src/structs/framework_options.rs b/src/structs/framework_options.rs index 4812ce9e1bd5..d472790869c5 100644 --- a/src/structs/framework_options.rs +++ b/src/structs/framework_options.rs @@ -23,6 +23,8 @@ pub struct FrameworkOptions { /// If individual commands add their own check, both callbacks are run and must return true. #[derivative(Debug = "ignore")] pub command_check: Option) -> BoxFuture<'_, Result>>, + /// If set to true, skips command checks if command was issued by [`FrameworkOptions::owners`] + pub skip_checks_for_owners: bool, /// Default set of allowed mentions to use for all responses /// /// By default, user pings are allowed and role pings and everyone pings are filtered @@ -36,7 +38,7 @@ pub struct FrameworkOptions { /// If `true`, disables automatic cooldown handling before every command invocation. /// /// Useful for implementing custom cooldown behavior. See [`crate::Command::cooldowns`] and - /// the methods on [`crate::Cooldowns`] for how to do that. + /// the methods on [`crate::CooldownTracker`] for how to do that. pub manual_cooldowns: bool, /// If `true`, changes behavior of guild_only command check to abort execution if the guild is /// not in cache. @@ -46,12 +48,15 @@ pub struct FrameworkOptions { /// Called on every Discord event. Can be used to react to non-command events, like messages /// deletions or guild updates. #[derivative(Debug = "ignore")] - pub listener: for<'a> fn( + pub event_handler: for<'a> fn( &'a serenity::FullEvent, crate::FrameworkContext<'a, U, E>, // TODO: redundant with framework &'a U, ) -> BoxFuture<'a, Result<(), E>>, + /// Renamed to [`Self::event_handler`]! + #[deprecated = "renamed to event_handler"] + pub listener: (), /// Prefix command specific options. pub prefix_options: crate::PrefixFrameworkOptions, /// User IDs which are allowed to use owners_only commands @@ -85,6 +90,7 @@ where E: std::fmt::Display + std::fmt::Debug + Send, { fn default() -> Self { + #[allow(deprecated)] // we need to set the listener field Self { commands: Vec::new(), on_error: |error| { @@ -94,10 +100,12 @@ where } }) }, - listener: |_, _, _| Box::pin(async { Ok(()) }), + event_handler: |_, _, _| Box::pin(async { Ok(()) }), + listener: (), pre_command: |_| Box::pin(async {}), post_command: |_| Box::pin(async {}), command_check: None, + skip_checks_for_owners: false, allowed_mentions: Some( serenity::CreateAllowedMentions::default() // Only support direct user pings by default diff --git a/src/structs/prefix.rs b/src/structs/prefix.rs index f37dc9ad4c03..c77701e11123 100644 --- a/src/structs/prefix.rs +++ b/src/structs/prefix.rs @@ -12,6 +12,8 @@ pub enum MessageDispatchTrigger { /// The message was edited, and was not a valid invocation pre-edit (i.e. user typoed the /// command, then fixed it) MessageEditFromInvalid, + #[doc(hidden)] + __NonExhaustive, } /// Prefix-specific context passed to command invocations. @@ -22,7 +24,7 @@ pub enum MessageDispatchTrigger { pub struct PrefixContext<'a, U, E> { /// Serenity's context, like HTTP or cache #[derivative(Debug = "ignore")] - pub discord: &'a serenity::Context, + pub serenity_context: &'a serenity::Context, /// The invoking user message pub msg: &'a serenity::Message, /// Prefix used by the user to invoke this command @@ -77,6 +79,8 @@ pub enum Prefix { Literal(&'static str), /// Regular expression which matches the prefix Regex(regex::Regex), + #[doc(hidden)] + __NonExhaustive, } /// Prefix-specific framework configuration diff --git a/src/structs/slash.rs b/src/structs/slash.rs index ee1acb53daf7..5b0f93dc9c4b 100644 --- a/src/structs/slash.rs +++ b/src/structs/slash.rs @@ -15,6 +15,7 @@ pub enum CommandOrAutocompleteInteraction<'a> { Command(&'a serenity::CommandInteraction), /// An autocomplete interaction Autocomplete(&'a serenity::CommandInteraction), + // Not non_exhaustive, this type is deliberately just two possible variants } impl<'a> CommandOrAutocompleteInteraction<'a> { @@ -91,7 +92,7 @@ impl<'a> CommandOrAutocompleteInteraction<'a> { pub struct ApplicationContext<'a, U, E> { /// Serenity's context, like HTTP or cache #[derivative(Debug = "ignore")] - pub discord: &'a serenity::Context, + pub serenity_context: &'a serenity::Context, /// The interaction which triggered this command execution. pub interaction: CommandOrAutocompleteInteraction<'a>, /// Slash command arguments @@ -148,7 +149,7 @@ impl ApplicationContext<'_, U, E> { { interaction .create_response( - self.discord, + self.serenity_context, serenity::CreateInteractionResponse::Defer( serenity::CreateInteractionResponseMessage::default().ephemeral(ephemeral), ), @@ -182,6 +183,8 @@ pub enum ContextMenuCommandAction { serenity::Message, ) -> BoxFuture<'_, Result<(), crate::FrameworkError<'_, U, E>>>, ), + #[doc(hidden)] + __NonExhaustive, } impl Copy for ContextMenuCommandAction {} impl Clone for ContextMenuCommandAction { @@ -197,6 +200,8 @@ pub struct CommandParameterChoice { pub name: String, /// Localized labels with locale string as the key (slash-only) pub localizations: std::collections::HashMap, + #[doc(hidden)] + pub __non_exhaustive: (), } /// A single parameter of a [`crate::Command`] @@ -204,7 +209,11 @@ pub struct CommandParameterChoice { #[derivative(Debug(bound = ""))] pub struct CommandParameter { /// Name of this command parameter - pub name: String, + /// + /// This field is optional because text commands (prefix commands) don't need parameter names. + /// This field is None when a pattern like `_` or `CodeBlock { code, lang, .. }` is used in + /// place of a name in the function parameter. + pub name: Option, /// Localized names with locale string as the key (slash-only) pub name_localizations: std::collections::HashMap, /// Description of the command. Required for slash commands @@ -243,6 +252,8 @@ pub struct CommandParameter { Result, >, >, + #[doc(hidden)] + pub __non_exhaustive: (), } impl CommandParameter { @@ -251,7 +262,7 @@ impl CommandParameter { pub fn create_as_slash_command_option(&self) -> Option { let mut b = serenity::CreateCommandOption::new( serenity::CommandOptionType::Unknown(0), // Will be overwritten by type_setter below - &self.name, + self.name.as_ref()?, self.description .as_deref() .unwrap_or("A slash command parameter"), diff --git a/src/track_edits.rs b/src/track_edits.rs index cdde17a318de..880dab79c6b8 100644 --- a/src/track_edits.rs +++ b/src/track_edits.rs @@ -3,6 +3,17 @@ use crate::serenity_prelude as serenity; +/// A single cached command invocation +#[derive(Debug)] +struct CachedInvocation { + /// User message that triggered this command invocation + user_msg: serenity::Message, + /// Associated bot response of this command invocation + bot_response: Option, + /// Whether the bot response should be deleted when the user deletes their message + track_deletion: bool, +} + /// Stores messages and the associated bot responses in order to implement poise's edit tracking /// feature. #[derive(Debug)] @@ -11,7 +22,7 @@ pub struct EditTracker { max_duration: std::time::Duration, /// Cache, which stores invocation messages, and the corresponding bot response message if any // TODO: change to `OrderedMap)>`? - cache: Vec<(serenity::Message, Option)>, + cache: Vec, } impl EditTracker { @@ -39,10 +50,10 @@ impl EditTracker { match self .cache .iter_mut() - .find(|(user_msg, _)| user_msg.id == user_msg_update.id) + .find(|invocation| invocation.user_msg.id == user_msg_update.id) { - Some((user_msg, response)) => { - if ignore_edits_if_not_yet_responded && response.is_none() { + Some(invocation) => { + if ignore_edits_if_not_yet_responded && invocation.bot_response.is_none() { return None; } @@ -55,8 +66,8 @@ impl EditTracker { return None; } - user_msg_update.apply_to_message(user_msg); - Some((user_msg.clone(), true)) + user_msg_update.apply_to_message(&mut invocation.user_msg); + Some((invocation.user_msg.clone(), true)) } None => { if ignore_edits_if_not_yet_responded { @@ -69,11 +80,33 @@ impl EditTracker { } } + /// Removes this command invocation from the cache and returns the associated bot response, + /// if the command invocation is cached, and it has an associated bot response, and the command + /// is marked track_deletion + pub fn process_message_delete( + &mut self, + deleted_message_id: serenity::MessageId, + ) -> Option { + let invocation = self.cache.remove( + self.cache + .iter() + .position(|invocation| invocation.user_msg.id == deleted_message_id)?, + ); + if invocation.track_deletion { + invocation.bot_response + } else { + None + } + } + /// Forget all of the messages that are older than the specified duration. pub fn purge(&mut self) { let max_duration = self.max_duration; - self.cache.retain(|(user_msg, _)| { - let last_update = user_msg.edited_timestamp.unwrap_or(user_msg.timestamp); + self.cache.retain(|invocation| { + let last_update = invocation + .user_msg + .edited_timestamp + .unwrap_or(invocation.user_msg.timestamp); let age = serenity::Timestamp::now().unix_timestamp() - last_update.unix_timestamp(); age < max_duration.as_secs() as i64 }); @@ -84,11 +117,11 @@ impl EditTracker { &self, user_msg_id: serenity::MessageId, ) -> Option<&serenity::Message> { - let (_, bot_response) = self + let invocation = self .cache .iter() - .find(|(user_msg, _)| user_msg.id == user_msg_id)?; - bot_response.as_ref() + .find(|invocation| invocation.user_msg.id == user_msg_id)?; + invocation.bot_response.as_ref() } /// Notify the [`EditTracker`] that the given user message should be associated with the given @@ -97,20 +130,37 @@ impl EditTracker { &mut self, user_msg: &serenity::Message, bot_response: serenity::Message, + track_deletion: bool, ) { - if let Some((_, r)) = self.cache.iter_mut().find(|(m, _)| m.id == user_msg.id) { - *r = Some(bot_response); + if let Some(invocation) = self + .cache + .iter_mut() + .find(|invocation| invocation.user_msg.id == user_msg.id) + { + invocation.bot_response = Some(bot_response); } else { - self.cache.push((user_msg.clone(), Some(bot_response))); + self.cache.push(CachedInvocation { + user_msg: user_msg.clone(), + bot_response: Some(bot_response), + track_deletion, + }); } } /// Store that this command is currently running; so that if the command is editing its own /// invocation message (e.g. removing embeds), we don't accidentally treat it as an /// `execute_untracked_edits` situation and start an infinite loop - pub fn track_command(&mut self, user_msg: &serenity::Message) { - if !self.cache.iter().any(|(m, _)| m.id == user_msg.id) { - self.cache.push((user_msg.clone(), None)); + pub fn track_command(&mut self, user_msg: &serenity::Message, track_deletion: bool) { + if !self + .cache + .iter() + .any(|invocation| invocation.user_msg.id == user_msg.id) + { + self.cache.push(CachedInvocation { + user_msg: user_msg.clone(), + bot_response: None, + track_deletion, + }); } } } diff --git a/src/util.rs b/src/util.rs index c5199c92e987..3c614bfa87d0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -17,6 +17,7 @@ impl OrderedMap { } /// Finds a value in the map by the given key + #[allow(dead_code)] pub fn get(&self, k: &K) -> Option<&V> { self.0 .iter() @@ -25,6 +26,7 @@ impl OrderedMap { } /// Inserts a key value pair into the map + #[allow(dead_code)] pub fn insert(&mut self, k: K, v: V) { match self.0.iter_mut().find(|entry| entry.0 == k) { Some(entry) => entry.1 = v,