Skip to content

Commit

Permalink
implement pretty_help (#233)
Browse files Browse the repository at this point in the history
* pretty_help init commit. just copy help

* pretty_help_all_commands

* simplify help single command function

* initial impl of pretty help single command

* pretty help finalize

* cargo fmt

* Revert functional Verschlimmbesserung

* Add pretty_help to feature_showcase

Mainly so I can test.. although it doesn't even work rn bc there's so many commands it exceeds Discord limits lol

* Let's not hijack another help command's config struct

* Hijack addendum

* Truncate to max embed len

* improve formatting for commands with no descrioption

* bugfix and add embed color

---------

Co-authored-by: Jannik Schaper <jannik.a.schaper@web.de>
  • Loading branch information
asibahi and kangalio authored Jan 21, 2024
1 parent 0471b14 commit ec19915
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 1 deletion.
10 changes: 10 additions & 0 deletions examples/feature_showcase/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ pub async fn help(ctx: Context<'_>, command: Option<String>) -> Result<(), Error
poise::builtins::help(ctx, command.as_deref(), configuration).await?;
Ok(())
}

#[poise::command(slash_command, prefix_command)]
pub async fn pretty_help(ctx: Context<'_>, command: Option<String>) -> Result<(), Error> {
let configuration = poise::builtins::PrettyHelpConfiguration {
// [configure aspects about the help message here]
..Default::default()
};
poise::builtins::pretty_help(ctx, command.as_deref(), configuration).await?;
Ok(())
}
1 change: 1 addition & 0 deletions examples/feature_showcase/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async fn main() {
#[cfg(feature = "cache")]
builtins::servers(),
builtins::help(),
builtins::pretty_help(),
checks::shutdown(),
checks::modonly(),
checks::delete(),
Expand Down
2 changes: 1 addition & 1 deletion src/builtins/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl TwoColumnList {
}

/// Get the prefix from options
async fn get_prefix_from_options<U, E>(ctx: crate::Context<'_, U, E>) -> Option<String> {
pub(super) async fn get_prefix_from_options<U, E>(ctx: crate::Context<'_, U, E>) -> Option<String> {
let options = &ctx.framework().options().prefix_options;
match &options.prefix {
Some(fixed_prefix) => Some(fixed_prefix.clone()),
Expand Down
3 changes: 3 additions & 0 deletions src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
mod help;
pub use help::*;

mod pretty_help;
pub use pretty_help::*;

mod register;
pub use register::*;

Expand Down
276 changes: 276 additions & 0 deletions src/builtins/pretty_help.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//! Contains a built-in help command and surrounding infrastructure that uses embeds.
use crate::{serenity_prelude as serenity, CreateReply};
use std::fmt::Write as _;

/// Optional configuration for how the help message from [`pretty_help()`] looks
pub struct PrettyHelpConfiguration<'a> {
/// Extra text displayed at the bottom of your message. Can be used for help and tips specific
/// to your bot
pub extra_text_at_bottom: &'a str,
/// Whether to make the response ephemeral if possible. Can be nice to reduce clutter
pub ephemeral: bool,
/// Whether to list context menu commands as well
pub show_context_menu_commands: bool,
/// Whether to list context menu commands as well
pub show_subcommands: bool,
/// Whether to include [`crate::Command::description`] (above [`crate::Command::help_text`]).
pub include_description: bool,
/// Color of the Embed
pub color: (u8,u8,u8),
#[doc(hidden)]
pub __non_exhaustive: (),
}

impl Default for PrettyHelpConfiguration<'_> {
fn default() -> Self {
Self {
extra_text_at_bottom: "",
ephemeral: true,
show_context_menu_commands: false,
show_subcommands: false,
include_description: true,
color: (0, 110, 51),
__non_exhaustive: (),
}
}
}

/// A help command that works similarly to `builtin::help` butt outputs text in an embed.
///
pub async fn pretty_help<U, E>(
ctx: crate::Context<'_, U, E>,
command: Option<&str>,
config: PrettyHelpConfiguration<'_>,
) -> Result<(), serenity::Error> {
match command {
Some(command) => pretty_help_single_command(ctx, command, config).await,
None => pretty_help_all_commands(ctx, config).await,
}
}

/// Printing an overview of all commands (e.g. `~help`)
async fn pretty_help_all_commands<U, E>(
ctx: crate::Context<'_, U, E>,
config: PrettyHelpConfiguration<'_>,
) -> Result<(), serenity::Error> {
let mut categories = crate::util::OrderedMap::new();
let commands = ctx.framework().options().commands.iter().filter(|cmd| {
!cmd.hide_in_help
&& (cmd.prefix_action.is_some()
|| cmd.slash_action.is_some()
|| (cmd.context_menu_action.is_some() && config.show_context_menu_commands))
});

for cmd in commands {
categories
.get_or_insert_with(cmd.category.as_deref(), Vec::new)
.push(cmd);
}

let options_prefix = super::help::get_prefix_from_options(ctx).await;

let fields = categories
.into_iter()
.filter(|(_, cmds)| !cmds.is_empty())
.map(|(category, mut cmds)| {
// get context menu items at the bottom
cmds.sort_by_key(|cmd| cmd.slash_action.is_none() && cmd.prefix_action.is_none());

let mut buffer = String::new();

for cmd in cmds {
let name = cmd.context_menu_name.as_deref().unwrap_or(&cmd.name);
let prefix = format_cmd_prefix(cmd, &options_prefix);

if let Some(description) = cmd.description.as_deref() {
writeln!(buffer, "{}{}`: *{}*", prefix, name, description).ok();
} else {
writeln!(buffer, "{}{}`.", prefix, name).ok();
}

if config.show_subcommands {
for sbcmd in &cmd.subcommands {
let name = sbcmd.context_menu_name.as_deref().unwrap_or(&sbcmd.name);
let prefix = format_cmd_prefix(sbcmd, &options_prefix);

if let Some(description) = sbcmd.description.as_deref() {
writeln!(buffer, "> {}{}`: *{}*", prefix, name, description).ok();
} else {
writeln!(buffer, "> {}{}`.", prefix, name).ok();
}
}
}
}
if let Some((i, _)) = buffer.char_indices().nth(1024) {
buffer.truncate(i);
}
(category.unwrap_or_default(), buffer, false)
})
.collect::<Vec<_>>();

let embed = serenity::CreateEmbed::new()
.title("Help")
.fields(fields)
.color(config.color)
.footer(serenity::CreateEmbedFooter::new(
config.extra_text_at_bottom,
));

let reply = crate::CreateReply::default()
.embed(embed)
.ephemeral(config.ephemeral);

ctx.send(reply).await?;

Ok(())
}

/// Figures out which prefix a command should have
fn format_cmd_prefix<U, E>(cmd: &crate::Command<U, E>, options_prefix: &Option<String>) -> String {
if cmd.slash_action.is_some() {
"`/".into()
} else if cmd.prefix_action.is_some() {
format!("`{}", options_prefix.as_deref().unwrap_or_default())
} else if cmd.context_menu_action.is_some() {
match cmd.context_menu_action {
Some(crate::ContextMenuCommandAction::Message(_)) => "Message menu: `".into(),
Some(crate::ContextMenuCommandAction::User(_)) => "User menu: `".into(),
Some(crate::ContextMenuCommandAction::__NonExhaustive) | None => {
unreachable!()
}
}
} else {
"`".into()
}
}

/// Code for printing help of a specific command (e.g. `~help my_command`)
async fn pretty_help_single_command<U, E>(
ctx: crate::Context<'_, U, E>,
command_name: &str,
config: PrettyHelpConfiguration<'_>,
) -> Result<(), serenity::Error> {
let commands = &ctx.framework().options().commands;

// Try interpret the command name as a context menu command first
let command = commands
.iter()
.find(|cmd| {
cmd.context_menu_name
.as_ref()
.is_some_and(|n| n.eq_ignore_ascii_case(command_name))
})
// Then interpret command name as a normal command (possibly nested subcommand)
.or(crate::find_command(commands, command_name, true, &mut vec![]).map(|(c, _, _)| c));

let Some(command) = command else {
ctx.send(
CreateReply::default()
.content(format!("No such command `{}`", command_name))
.ephemeral(config.ephemeral),
)
.await?;

return Ok(());
};

let mut invocations = Vec::new();
let mut subprefix = None;

if command.slash_action.is_some() {
invocations.push(format!("`/{}`", command.name));
subprefix = Some(format!("> `/{}`", command.name));
}
if command.prefix_action.is_some() {
let prefix = super::help::get_prefix_from_options(ctx)
.await
// This can happen if the prefix is dynamic, and the callback fails
// due to help being invoked with slash or context menu commands.
.unwrap_or_else(|| String::from("<prefix>"));
invocations.push(format!("`{}{}`", prefix, command.name));
subprefix = subprefix.or(Some(format!("> `{}{}`", prefix, command.name)));
}
if command.context_menu_name.is_some() && command.context_menu_action.is_some() {
let kind = match command.context_menu_action {
Some(crate::ContextMenuCommandAction::User(_)) => "user",
Some(crate::ContextMenuCommandAction::Message(_)) => "message",
Some(crate::ContextMenuCommandAction::__NonExhaustive) | None => unreachable!(),
};
invocations.push(format!(
"`{}` (on {})",
command
.context_menu_name
.as_deref()
.unwrap_or(&command.name),
kind
));
subprefix = subprefix.or(Some(String::from("> ")));
}
// At least one of the three if blocks should have triggered
assert!(!invocations.is_empty());
assert!(subprefix.is_some());

let invocations = invocations
.into_iter()
.reduce(|x, y| format!("{x}\n{y}"))
.map(|s| ("", s, false));

let description = match (&command.description, &command.help_text) {
(Some(description), Some(help_text)) if config.include_description => {
format!("{}\n\n{}", description, help_text)
}
(_, Some(help_text)) => help_text.clone(),
(Some(description), None) => description.clone(),
(None, None) => "No help available".to_string(),
};

let parameters = command
.parameters
.iter()
.map(|parameter| {
let req = if parameter.required {
"required"
} else {
"optional"
};
if let Some(description) = parameter.description.as_deref() {
format!("`{}` ({}) *{} *.", parameter.name, req, description)
} else {
format!("`{}` ({}).", parameter.name, req)
}
})
.reduce(|x, y| format!("{x}\n{y}"))
.map(|s| ("Parameters", s, false));

let sbcmds = command
.subcommands
.iter()
.map(|sbcmd| {
let prefix = format_cmd_prefix(sbcmd, &subprefix); // i have no idea about this really
let name = sbcmd.context_menu_name.as_deref().unwrap_or(&sbcmd.name);
if let Some(description) = sbcmd.description.as_deref() {
format!("> {}{}`: *{} *", prefix, name, description)
} else {
format!("> {}{}`", prefix, name,)
}
})
.reduce(|x, y| format!("{x}\n{y}"))
.map(|s| ("Subcommands", s, false));

let fields = invocations
.into_iter()
.chain(parameters.into_iter())
.chain(sbcmds.into_iter());

let embed = serenity::CreateEmbed::default()
.description(description)
.fields(fields);

let reply = CreateReply::default()
.embed(embed)
.ephemeral(config.ephemeral);

ctx.send(reply).await?;
Ok(())
}

0 comments on commit ec19915

Please sign in to comment.