Commands
The Discord bot uses Poise for command handling with native slash command support and prefix command fallback.
Command Categories
Public Commands
Public commands are available to all users without permission checks.
| Command | Description |
|---|---|
ping | Check if the bot is responsive |
help | Show available commands |
info | Show bot information |
Admin Commands
Admin commands require the discord:bot.admin permission.
| Command | Description |
|---|---|
config | View bot configuration (endpoints, environment) |
settings | Interactive bot settings with language selection |
The /settings command features an interactive dropdown menu to change the bot's default language at runtime.
Moderation Commands
Moderation commands require granular permissions per action.
| Command | Permission | Description |
|---|---|---|
warn | discord:guild.warn | Warn a user |
kick | discord:guild.kick | Kick a user from the server |
ban | discord:guild.ban | Ban a user from the server |
mute | discord:guild.timeout | Mute (timeout) a user |
Sync Commands
Sync commands allow synchronizing Discord data to the Heimdall database.
| Command | Permission | Description |
|---|---|---|
/sync | - | Show sync command help |
/sync all | discord:sync | Sync all Discord data (guilds, channels, roles) |
/sync guilds | discord:sync | Sync guilds only (without channels/roles) |
/sync channels | discord:guild.sync | Sync channels for the current guild |
/sync roles | discord:guild.sync | Sync roles for the current guild |
/sync members | discord:guild.sync | Sync members for the current guild |
Note: /sync all and /sync guilds require the discord:sync permission for discovery of new guilds. The guild-specific sync commands (channels, roles, members) require discord:guild.sync and can only be used within a server.
Creating Commands with Poise
Basic Command Structure
Commands are defined using the #[poise::command] macro which supports both slash commands and prefix commands automatically:
use crate::bot::data::{Context, Error};
use rust_i18n::t;
use poise::CreateReply;
/// Check if the bot is responsive
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
let start = std::time::Instant::now();
let reply = ctx.say("Pinging...").await?;
let latency = start.elapsed().as_millis();
reply.edit(ctx, CreateReply::default()
.content(t!("commands.ping.response", latency = latency))
).await?;
Ok(())
}
Command with Embeds
use crate::bot::data::{Context, Error};
use crate::embeds::builders::heimdall_embed;
use poise::serenity_prelude::CreateMessage;
use rust_i18n::t;
/// Show bot information
#[poise::command(slash_command, prefix_command)]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let uptime = ctx.data().start_time.elapsed();
let embed = heimdall_embed()
.title(t!("commands.info.title"))
.field(t!("commands.info.version"), env!("CARGO_PKG_VERSION"), true)
.field(t!("commands.info.uptime"), format_duration(uptime), true);
ctx.send(poise::CreateReply::default().embed(embed)).await?;
Ok(())
}
Command with Parameters
Poise supports rich parameter types with automatic parsing:
use poise::serenity_prelude as serenity;
/// Kick a user from the server
#[poise::command(
slash_command,
prefix_command,
guild_only,
required_permissions = "KICK_MEMBERS",
category = "Moderation"
)]
pub async fn kick(
ctx: Context<'_>,
#[description = "User to kick"] user: serenity::User,
#[description = "Reason for kicking"] reason: Option<String>,
) -> Result<(), Error> {
let reason_text = reason.unwrap_or_else(|| "No reason provided".to_string());
// Perform kick logic...
ctx.say(format!("Kicked {} for: {}", user.name, reason_text)).await?;
Ok(())
}
Command with Permission Check
Use the check attribute for RBAC permission validation:
/// Check if user has admin permission
async fn check_admin_permission(ctx: Context<'_>) -> Result<bool, Error> {
let user_id = ctx.author().id.get();
let rbac = &ctx.data().rbac_client;
match rbac.check_permission(user_id, "discord:bot.admin").await {
Ok(has_permission) => Ok(has_permission),
Err(e) => {
tracing::warn!(error = %e, "Failed to check admin permission");
Ok(false)
}
}
}
/// View bot configuration (admin only)
#[poise::command(
slash_command,
prefix_command,
check = "check_admin_permission",
category = "Admin"
)]
pub async fn config(ctx: Context<'_>) -> Result<(), Error> {
// Admin-only command logic...
Ok(())
}
Exporting Commands
All commands are exported from commands/mod.rs:
use crate::bot::data::{Data, Error};
pub mod general;
pub mod admin;
pub mod moderate;
pub use general::*;
pub use admin::*;
pub use moderate::*;
/// Returns all registered commands
pub fn all_commands() -> Vec<poise::Command<Data, Error>> {
vec![
// General commands (public)
general::ping(),
general::help(),
general::info(),
// Admin commands (permission check)
admin::config(),
admin::settings(),
// Moderation commands (permission check)
moderate::warn(),
moderate::kick(),
moderate::ban(),
moderate::mute(),
]
}
Framework Setup
The Poise framework is configured in bot/client.rs:
use poise::serenity_prelude as serenity;
use std::sync::Arc;
use crate::bot::data::{Data, Error, Framework};
use crate::commands;
use crate::config::Settings;
pub async fn build_framework(settings: Arc<Settings>) -> Result<Framework, Error> {
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: commands::all_commands(),
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some(settings.bot.prefix.clone()),
case_insensitive_commands: true,
mention_as_prefix: true,
..Default::default()
},
on_error: |error| Box::pin(on_error(error)),
pre_command: |ctx| Box::pin(pre_command(ctx)),
post_command: |ctx| Box::pin(post_command(ctx)),
event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data))
},
..Default::default()
})
.setup(move |ctx, ready, framework| {
Box::pin(async move {
tracing::info!(
user = %ready.user.name,
guilds = ready.guilds.len(),
"Bot connected to Discord"
);
// Register slash commands globally
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
tracing::info!("Slash commands registered globally");
Ok(Data::new(settings))
})
})
.build();
Ok(framework)
}
Command Attributes
Poise provides many command attributes:
| Attribute | Description |
|---|---|
slash_command | Register as a slash command |
prefix_command | Enable prefix command support |
guild_only | Only available in servers |
owners_only | Only bot owners can use |
required_permissions | Discord permissions required |
check | Custom permission check function |
category | Command category for help |
aliases | Alternative command names |
rename | Different slash command name |
subcommands | Nested subcommands |
Error Handling
Errors are captured by the on_error handler and sent to Sentry:
async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
tracing::error!(
command = ctx.command().name,
user = %ctx.author().id,
error = %error,
"Command error"
);
sentry::capture_message(
&format!("Command '{}' error: {}", ctx.command().name, error),
sentry::Level::Error,
);
let _ = ctx.say(t!("errors.internal_error")).await;
}
poise::FrameworkError::CommandCheckFailed { error, ctx, .. } => {
if let Some(error) = error {
tracing::warn!(
command = ctx.command().name,
error = %error,
"Command check failed"
);
}
let _ = ctx.say(t!("errors.permission_denied")).await;
}
// ... other error types
error => {
if let Err(e) = poise::builtins::on_error(error).await {
tracing::error!(error = %e, "Error while handling error");
}
}
}
}
Pre/Post Command Hooks
The framework uses hooks that run before and after every command.
/// Runs before every command
async fn pre_command(ctx: poise::Context<'_, Data, Error>) {
tracing::info!(
user_id = %ctx.author().id,
user_name = %ctx.author().name,
command = ctx.command().name,
guild_id = ?ctx.guild_id(),
"Command invoked"
);
}
/// Runs after every command - handles automatic audit logging
async fn post_command(ctx: poise::Context<'_, Data, Error>) {
let command_name = &ctx.command().name;
let discord_id = ctx.author().id.to_string();
let guild_id = ctx.guild_id().map(|g| g.to_string());
let interaction_id = ctx.id().to_string();
// Look up Heimdall user ID from Discord ID
let user_id = ctx.data().graphql_client
.find_user_by_platform("discord", &discord_id)
.await
.ok()
.flatten();
// Get any custom metadata set by the command
let extra_metadata = ctx.data().get_command_metadata(&interaction_id);
// Log command execution to audit log
ctx.data().audit_client
.log_command_executed(
user_id.as_deref(),
&discord_id,
command_name,
guild_id.as_deref(),
extra_metadata, // Custom metadata merged with base
)
.await;
// Clean up the metadata entry
ctx.data().clear_command_metadata(&interaction_id);
}
Automatic Audit Logging
All commands are automatically logged to the Heimdall audit system via the post_command hook. You don't need to manually call audit methods.
Adding Custom Metadata
Commands can enrich their audit events with custom metadata:
#[poise::command(slash_command, guild_only)]
pub async fn kick(
ctx: Context<'_>,
user: serenity::User,
reason: Option<String>,
) -> Result<(), Error> {
let interaction_id = ctx.id().to_string();
let reason = reason.unwrap_or_else(|| "No reason provided".to_string());
// Perform the kick
match guild_id.kick_with_reason(&ctx.http(), user.id, &reason).await {
Ok(_) => {
// Set custom metadata for audit logging
ctx.data().set_command_metadata(
&interaction_id,
serde_json::json!({
"action": "kick",
"target_user_id": user.id.to_string(),
"target_username": user.name,
"reason": reason,
"success": true,
}),
);
// ... send success message
}
Err(e) => {
// Set error metadata
ctx.data().set_command_metadata(
&interaction_id,
serde_json::json!({
"action": "kick",
"target_user_id": user.id.to_string(),
"success": false,
"error": e.to_string(),
}),
);
// ... send error message
}
}
Ok(())
}
The custom metadata is automatically merged with the base metadata (discord_id, command, guild_id) in the audit event.
Advanced Features
Autocomplete
Provide dynamic suggestions as users type command parameters:
use futures::Stream;
/// Autocomplete function for user names
async fn autocomplete_username<'a>(
ctx: Context<'_>,
partial: &'a str,
) -> impl Stream<Item = String> + 'a {
// Fetch users from API or cache
let users = vec!["Alice", "Bob", "Charlie", "David"];
futures::stream::iter(users)
.filter(move |name| futures::future::ready(
name.to_lowercase().starts_with(&partial.to_lowercase())
))
.map(|name| name.to_string())
}
/// Command with autocomplete
#[poise::command(slash_command)]
pub async fn greet(
ctx: Context<'_>,
#[description = "User to greet"]
#[autocomplete = "autocomplete_username"]
name: String,
) -> Result<(), Error> {
ctx.say(format!("Hello, {}!", name)).await?;
Ok(())
}
Modals
Collect structured input via Discord modal dialogs:
use poise::Modal;
/// Define a modal structure
#[derive(Debug, Modal)]
#[name = "Report Form"]
struct ReportModal {
#[name = "Title"]
#[placeholder = "Brief description of the issue"]
title: String,
#[name = "Description"]
#[paragraph]
#[placeholder = "Detailed description..."]
description: Option<String>,
}
/// Command that opens a modal
#[poise::command(slash_command)]
pub async fn report(ctx: Context<'_>) -> Result<(), Error> {
let data = ReportModal::execute(ctx).await?;
if let Some(modal_data) = data {
ctx.say(format!(
"Report received!\nTitle: {}\nDescription: {}",
modal_data.title,
modal_data.description.unwrap_or_default()
)).await?;
}
Ok(())
}
Context Menus
Add right-click actions for users and messages:
/// User context menu - appears when right-clicking a user
#[poise::command(context_menu_command = "User Info")]
pub async fn user_info(
ctx: Context<'_>,
#[description = "User to get info about"] user: serenity::User,
) -> Result<(), Error> {
let embed = smutje_embed()
.title(format!("User: {}", user.name))
.field("ID", user.id.to_string(), true)
.field("Created", user.created_at().to_string(), true)
.thumbnail(user.face());
ctx.send(CreateReply::default().embed(embed)).await?;
Ok(())
}
/// Message context menu - appears when right-clicking a message
#[poise::command(context_menu_command = "Quote Message")]
pub async fn quote_message(
ctx: Context<'_>,
#[description = "Message to quote"] msg: serenity::Message,
) -> Result<(), Error> {
let embed = smutje_embed()
.description(format!("> {}", msg.content))
.footer(CreateEmbedFooter::new(format!("— {}", msg.author.name)));
ctx.send(CreateReply::default().embed(embed)).await?;
Ok(())
}
Subcommands
Organize related commands hierarchically:
/// Parent command with subcommands
#[poise::command(
slash_command,
prefix_command,
subcommands("role_add", "role_remove", "role_list"),
category = "Admin"
)]
pub async fn role(ctx: Context<'_>) -> Result<(), Error> {
// This runs when `/role` is called without a subcommand (prefix only)
ctx.say("Use `/role add`, `/role remove`, or `/role list`").await?;
Ok(())
}
/// Add a role to a user
#[poise::command(slash_command, prefix_command, rename = "add")]
pub async fn role_add(
ctx: Context<'_>,
#[description = "User to modify"] user: serenity::User,
#[description = "Role to add"] role: serenity::Role,
) -> Result<(), Error> {
// Add role logic...
ctx.say(format!("Added {} to {}", role.name, user.name)).await?;
Ok(())
}
/// Remove a role from a user
#[poise::command(slash_command, prefix_command, rename = "remove")]
pub async fn role_remove(
ctx: Context<'_>,
#[description = "User to modify"] user: serenity::User,
#[description = "Role to remove"] role: serenity::Role,
) -> Result<(), Error> {
// Remove role logic...
ctx.say(format!("Removed {} from {}", role.name, user.name)).await?;
Ok(())
}
/// List roles for a user
#[poise::command(slash_command, prefix_command, rename = "list")]
pub async fn role_list(
ctx: Context<'_>,
#[description = "User to check"] user: Option<serenity::User>,
) -> Result<(), Error> {
let target = user.as_ref().unwrap_or(ctx.author());
// List roles logic...
ctx.say(format!("Roles for {}: ...", target.name)).await?;
Ok(())
}
Component Interactions with Collectors
Handle button clicks and select menus with collectors.
Interactive Settings with Select Menu (Real Example)
The /settings command demonstrates an interactive dropdown for changing the bot's default language:
use poise::serenity_prelude::{
self as serenity, ComponentInteractionCollector, CreateActionRow,
CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption,
};
use std::time::Duration;
#[poise::command(slash_command, check = "check_admin_permission", category = "Admin")]
pub async fn settings(ctx: Context<'_>) -> Result<(), Error> {
let locale = get_command_locale(ctx);
let current_locale = ctx.data().get_default_locale().await;
// Create dropdown with label as default selected option
let label_text = t!("commands.settings.language.select_label", locale = locale);
let language_select = CreateSelectMenu::new(
"settings_language_select",
CreateSelectMenuKind::String {
options: vec![
// Label as default selected header option
CreateSelectMenuOption::new(label_text.to_string(), "label_header")
.description(t!("commands.settings.language.description", locale = locale))
.default_selection(true),
CreateSelectMenuOption::new("🇬🇧 English", "en"),
CreateSelectMenuOption::new("🇩🇪 Deutsch", "de"),
],
},
);
let reply = ctx.send(
CreateReply::default()
.embed(embed)
.components(vec![CreateActionRow::SelectMenu(language_select)])
.ephemeral(true)
).await?;
let message = reply.message().await?;
// Collect interactions with timeout
while let Some(interaction) = ComponentInteractionCollector::new(ctx.serenity_context())
.message_id(message.id)
.author_id(ctx.author().id)
.timeout(Duration::from_secs(60))
.await
{
if let serenity::ComponentInteractionDataKind::StringSelect { values } =
&interaction.data.kind
{
if let Some(selected) = values.first() {
// Ignore header label selection
if selected == "label_header" {
interaction.create_response(ctx.http(),
serenity::CreateInteractionResponse::Acknowledge).await?;
continue;
}
// Update runtime locale
ctx.data().set_default_locale(selected).await;
// Update message with new state
interaction.create_response(ctx.http(),
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.embed(updated_embed)
.components(updated_components)
)).await?;
}
}
}
// On timeout: remove components
let _ = reply.edit(ctx, CreateReply::default()
.embed(final_embed)
.components(vec![])).await;
Ok(())
}
Key Patterns:
- Use
.default_selection(true)on a "label" option to show it as selected initially - The label option uses a special value (e.g.,
"label_header") that's ignored when selected - Always use
.ephemeral(true)for admin commands - Remove components after timeout to prevent stale interactions
- Use
while letloop to handle multiple selections within the timeout
Buttons Example
use poise::serenity_prelude::{
CreateActionRow, CreateButton, ComponentInteractionCollector,
};
use std::time::Duration;
/// Command with interactive buttons
#[poise::command(slash_command)]
pub async fn confirm_action(ctx: Context<'_>) -> Result<(), Error> {
let buttons = vec![
CreateActionRow::Buttons(vec![
CreateButton::new("confirm")
.label("Confirm")
.style(serenity::ButtonStyle::Success),
CreateButton::new("cancel")
.label("Cancel")
.style(serenity::ButtonStyle::Danger),
])
];
let reply = ctx.send(
CreateReply::default()
.content("Are you sure?")
.components(buttons)
).await?;
// Wait for button click
let message = reply.message().await?;
if let Some(interaction) = ComponentInteractionCollector::new(ctx)
.message_id(message.id)
.author_id(ctx.author().id)
.timeout(Duration::from_secs(60))
.await
{
match interaction.data.custom_id.as_str() {
"confirm" => {
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Action confirmed!")
.components(vec![])
)
).await?;
}
"cancel" => {
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Action cancelled.")
.components(vec![])
)
).await?;
}
_ => {}
}
}
Ok(())
}
User-Installable Commands
Commands that users can install to their account (works in DMs and other servers):
/// Command installable by users
#[poise::command(
slash_command,
install_context = "User", // User-installable
interaction_context = "Guild | BotDm | PrivateChannel" // Works everywhere
)]
pub async fn my_stats(ctx: Context<'_>) -> Result<(), Error> {
// This command can be used anywhere once installed by the user
let user = ctx.author();
ctx.say(format!("Stats for {}: ...", user.name)).await?;
Ok(())
}