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-Show sync command help
/sync alldiscord:syncSync all Discord data (guilds, channels, roles)
/sync guildsdiscord:syncSync guilds only (without channels/roles)
/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 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:

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(())
}