Skip to main content

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.

CommandDescription
pingCheck if the bot is responsive
helpShow available commands
infoShow bot information

Admin Commands​

Admin commands require the discord:bot.admin permission.

CommandDescription
configView bot configuration (endpoints, environment)
settingsInteractive 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.

CommandPermissionDescription
warndiscord:guild.warnWarn a user
kickdiscord:guild.kickKick a user from the server
bandiscord:guild.banBan a user from the server
mutediscord:guild.timeoutMute (timeout) a user

Sync Commands​

Sync commands allow synchronizing Discord data to the Heimdall database.

CommandPermissionDescription
/sync alldiscord:bot.adminSync all Discord data (guilds, channels, roles)
/sync guildsdiscord:bot.adminDiscover/sync guilds only (without channels/roles)
/sync guilddiscord:guild.syncSync everything for the current guild (channels, roles, members)
/sync channelsdiscord:guild.syncSync channels for the current guild
/sync rolesdiscord:guild.syncSync roles for the current guild
/sync membersdiscord:guild.syncSync members for the current guild

Note: /sync all and /sync guilds are global operations and require the discord:bot.admin permission. The guild-specific sync types (guild, 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::smutje_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 = smutje_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};

mod admin;
mod general;
mod moderate;
mod sync;

/// 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 (require discord:bot.admin)
admin::config(),
admin::settings(),
// Sync commands (mixed permissions - see subcommands)
sync::sync(),
// Moderation commands (require granular discord:guild.*)
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:

AttributeDescription
slash_commandRegister as a slash command
prefix_commandEnable prefix command support
guild_onlyOnly available in servers
owners_onlyOnly bot owners can use
required_permissionsDiscord permissions required
checkCustom permission check function
categoryCommand category for help
aliasesAlternative command names
renameDifferent slash command name
subcommandsNested 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 let loop 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(())
}