Skip to main content

Command Basics

Faculty Bot uses the Poise framework for command handling. Commands support both slash commands and prefix commands.

Creating a Simple Command

1

Define the Command Function

Create a command function in the appropriate module (user.rs, administration.rs, or moderation.rs):
src/commands/user.rs
use crate::{prelude::Error, Context};

/// Show your XP
#[poise::command(
    slash_command,
    prefix_command,
    track_edits,
    name_localized("de", "xp"),
    description_localized("de", "Zeige deine XP")
)]
pub async fn xp(ctx: Context<'_>) -> Result<(), Error> {
    let pool = &ctx.data().db;
    let user_id = ctx.author().id.0 as i64;
    
    let user = sqlx::query_as::<sqlx::Postgres, structs::UserXP>(
        "SELECT * FROM user_xp WHERE user_id = $1"
    )
    .bind(user_id)
    .fetch_optional(pool)
    .await
    .map_err(Error::Database)?;
    
    if let Some(user) = user {
        ctx.send(|f| {
            f.embed(|e| {
                e.description(format!("You have {} XP (Level {})", 
                    user.user_xp, user.user_level))
            });
            f
        })
        .await
        .map_err(Error::Serenity)?;
    } else {
        ctx.say("You have no XP yet.").await.map_err(Error::Serenity)?;
    }
    
    Ok(())
}
2

Register the Command

Add your command to the commands vector in main.rs:
src/main.rs
commands: vec![
    register(),
    commands::user::verify(),
    commands::user::leaderboard(),
    commands::user::xp(),
    commands::user::my_new_command(), // Add here
    // ...
],
3

Export from Module

Ensure the command is public and exported from commands/mod.rs if needed:
src/commands/mod.rs
pub mod user;
pub mod administration;
pub mod moderation;

Command Attributes

Basic Attributes

#[poise::command(
    slash_command,          // Enable as slash command (/command)
    prefix_command,         // Enable as prefix command (>command)
    track_edits,            // Re-run command if message is edited
    guild_only,             // Only work in servers, not DMs
    ephemeral,              // Response only visible to user
)]

Permissions

#[poise::command(
    required_permissions = "MANAGE_GUILD",  // Discord permission required
    default_member_permissions = "MANAGE_GUILD",  // Default permission in slash command menu
    owners_only,  // Only bot owners can use
)]

Localization

#[poise::command(
    rename = "verify",  // Command name override
    name_localized("de", "verifizieren"),  // German name
    name_localized("ja", "確認"),  // Japanese name
    description_localized("de", "Verifiziere dich mit deiner E-Mail"),
)]

Custom Checks

#[poise::command(
    check = "executor_is_dev_or_admin"  // Custom permission function
)]
pub async fn admin_command(ctx: Context<'_>) -> Result<(), Error> {
    // ...
}

// Define the check function
async fn executor_is_dev_or_admin(ctx: Context<'_>) -> Result<bool, Error> {
    let member = ctx.author_member().await.unwrap();
    
    let has_perms = member.roles.contains(&ctx.data().config.roles.staffrole);
    
    Ok(has_perms)
}
See administration.rs:4-24

Command Parameters

Simple Parameters

#[poise::command(slash_command)]
pub async fn greet(
    ctx: Context<'_>,
    #[description = "The user to greet"] user: serenity::User,
    #[description = "Custom greeting message"] message: Option<String>,
) -> Result<(), Error> {
    let greeting = message.unwrap_or("Hello".to_string());
    ctx.say(format!("{}, {}!", greeting, user.name))
        .await
        .map_err(Error::Serenity)?;
    Ok(())
}

Parameter Attributes

#[poise::command(slash_command)]
pub async fn set_xp(
    ctx: Context<'_>,
    #[description = "Selected user"] user: serenity::User,
    #[description = "New XP"]
    #[min = 0]  // Minimum value
    #[max = 100000]  // Maximum value
    xp: i64,
) -> Result<(), Error> {
    // ...
}

Localized Parameters

#[poise::command(slash_command)]
pub async fn verify_init(
    ctx: Context<'_>,
    #[description = "Your student email address"]
    #[description_localized("de", "Deine Studierenden E-Mail Adresse")]
    #[name_localized("de", "email-adresse")]
    #[rename = "email"]
    email_used: String,
) -> Result<(), Error> {
    // ...
}
See user.rs:28-46

Channel Type Filtering

#[poise::command(slash_command)]
pub async fn post_rules(
    ctx: Context<'_>,
    #[description = "Channel to post in"]
    #[channel_types("Text")]  // Only allow text channels
    channel: Option<serenity::GuildChannel>,
) -> Result<(), Error> {
    // ...
}
See administration.rs:441-456

Subcommands

Create command groups with subcommands:
/// Base command for verification
#[poise::command(
    slash_command,
    prefix_command,
    track_edits,
    rename = "verify",
    guild_only,
    subcommands("init", "code")  // Define subcommands
)]
pub async fn verify(ctx: Context<'_>) -> Result<(), Error> {
    // This is only called in prefix context when no subcommand is provided
    Ok(())
}

/// Request a verification code
#[poise::command(
    slash_command,
    prefix_command,
    track_edits,
    guild_only,
)]
pub async fn init(
    ctx: Context<'_>,
    #[description = "Your student email"] email: String,
) -> Result<(), Error> {
    // Implementation
    Ok(())
}

/// Enter verification code
#[poise::command(
    slash_command,
    prefix_command,
    track_edits,
    guild_only,
)]
pub async fn code(
    ctx: Context<'_>,
    #[description = "The code you received"] code: String,
) -> Result<(), Error> {
    // Implementation
    Ok(())
}
Usage: See user.rs:10-204

Response Patterns

Simple Text Response

ctx.say("Hello, world!").await.map_err(Error::Serenity)?;

Embed Response

ctx.send(|f| {
    f.embed(|e| {
        e.title("XP Stats")
         .description(format!("You have {} XP", xp))
         .field("Level", level.to_string(), true)
         .color(0x00ff00)
    });
    f
})
.await
.map_err(Error::Serenity)?;

With Components (Buttons)

ctx.send(|f| {
    f.content("Choose an option:")
     .components(|c| {
         c.create_action_row(|a| {
             a.create_button(|b| {
                 b.style(serenity::ButtonStyle::Primary)
                  .label("Click me!")
                  .custom_id("my_button_id")
             })
         })
     })
})
.await
.map_err(Error::Serenity)?;
See tasks.rs:68-92

With File Attachments

use poise::serenity_prelude::AttachmentType;

let image_data = generate_image();

channel.send_message(&ctx, |f| {
    f.add_file(AttachmentType::Bytes {
        data: std::borrow::Cow::Borrowed(&image_data),
        filename: "levelup.png".to_string(),
    })
})
.await
.map_err(Error::Serenity)?;
See eventhandler.rs:134-140

Accessing Bot State

Database Access

let pool = &ctx.data().db;
let user = sqlx::query_as::<_, structs::UserXP>(
    "SELECT * FROM user_xp WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(pool)
.await
.map_err(Error::Database)?;

Configuration Access

let staff_role = ctx.data().config.roles.staffrole;
let xp_channel = ctx.data().config.channels.xp;
let chars_for_level = ctx.data().config.general.chars_for_level;

Shared State (DashMap)

// Insert
ctx.data().email_codes.insert(user_id, CodeEmailPair { 
    code: verification_code, 
    email: user_email 
});

// Get
let code_pair = ctx.data().email_codes.get(&user_id);

// Remove
ctx.data().email_codes.remove(&user_id);
See user.rs:76, 140, 194-199

Error Handling

Returning User-Friendly Errors

if !email.ends_with("@stud.hs-kempten.de") {
    return Err(Error::WithMessage(
        "Invalid email! Must end with @stud.hs-kempten.de".to_string()
    ));
}

Using Translations

use crate::prelude::translations::Lang;

let lang = match ctx.locale() {
    Some("de") => Lang::De,
    Some("ja") => Lang::Ja,
    _ => Lang::En,
};

if !email_valid {
    return Err(Error::WithMessage(lang.invalid_email().into()));
}
See user.rs:48-56

Database Error Conversion

let result = sqlx::query("INSERT INTO users VALUES ($1)")
    .bind(user_id)
    .execute(pool)
    .await
    .map_err(Error::Database)?;  // Convert sqlx::Error to Error::Database

Real-World Example: Leaderboard Command

Here’s a complete example from the codebase:
src/commands/user.rs
/// Show the Top 10 users by XP
#[poise::command(
    slash_command,
    prefix_command,
    track_edits,
    name_localized("de", "leaderboard"),
    description_localized("de", "Zeige die besten 10 Nutzer anhand ihrer XP")
)]
pub async fn leaderboard(ctx: Context<'_>) -> Result<(), Error> {
    let pool = &ctx.data().db;
    
    // Fetch top 10 users ordered by XP
    let users = sqlx::query_as::<sqlx::Postgres, structs::UserXP>(
        "SELECT * FROM user_xp ORDER BY user_xp DESC LIMIT 10"
    )
    .fetch_all(pool)
    .await
    .map_err(Error::Database)?;
    
    // Build leaderboard string
    let mut leaderboard = String::new();
    for (i, user) in users.iter().enumerate() {
        let user_discord = serenity::UserId(user.user_id as u64)
            .to_user(&ctx.serenity_context())
            .await
            .map_err(Error::Serenity)?;
        
        leaderboard.push_str(&format!(
            "{}. {} - {} XP\n",
            i + 1,
            user_discord.tag().replace("#0000", ""),
            user.user_xp
        ));
    }
    
    // Send embed response
    ctx.send(|f| {
        f.embed(|e| {
            e.title("Leaderboard");
            e.description(leaderboard);
            e
        });
        f
    })
    .await
    .map_err(Error::Serenity)?;
    
    Ok(())
}
See user.rs:206-250

Testing Commands

In Development

  1. Start the bot: cargo run
  2. In Discord:
    • Slash command: /your_command
    • Prefix command: >your_command

Register Slash Commands

After adding new slash commands:
>register
Or they’ll auto-register on bot startup (see main.rs:233-240).

Best Practices

  1. Always handle errors: Use .map_err() to convert to Error enum
  2. Use defer for slow commands: ctx.defer_or_broadcast().await?;
  3. Validate input: Check user input before database operations
  4. Use translations: Support multiple languages with rosetta-i18n
  5. Add descriptions: Provide clear descriptions for users
  6. Permission checks: Use required_permissions or custom checks
  7. Guild-only when needed: Use guild_only for server-specific commands
  8. Ephemeral for sensitive data: Use ephemeral for private responses

Next Steps

Build docs developers (and LLMs) love