Bot Module Development
What this skill does
Provides guidance on creating new protection modules, including commands, event handlers, and database entities in the Open Guard bot.
When to use
Use this skill when you need to:
- •Create a new protection or utility module
- •Add new slash commands to existing modules
- •Implement event handlers for Discord events
- •Add database entities and migrations
- •Configure module settings
⚠️ CRITICAL: Do Not Await Interactions in Commands
NEVER await button/select/menu interactions in command handlers. This is a hard rule in Open Guard.
Why?
- •Commands must return immediately
- •Awaiting interactions blocks the command handler
- •Interaction handlers are separate and asynchronous
- •Data should be transported via
custom_idparsing
The Pattern
rust
// ❌ WRONG: Awaiting interaction in command
#[poise::command(slash_command)]
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(poise::CreateReply::default()
.components(vec![
serenity::CreateButton::new("btn_confirm")
.label("Confirm")
.style(serenity::ButtonStyle::Success),
])
).await?;
// WRONG: This blocks the command!
let interaction = ctx.channel_id().await_component(&ctx).await?;
Ok(())
}
// ✅ CORRECT: Send and return immediately
#[poise::command(slash_command)]
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
let setup_id = generate_unique_id();
ctx.send(poise::CreateReply::default()
.content("Confirm the action")
.components(vec![
serenity::CreateButton::new(format!("btn_confirm_{}", setup_id))
.label("Confirm")
.style(serenity::ButtonStyle::Success),
])
).await?;
// ✅ Command returns immediately
Ok(())
}
// Handle interaction in event handler (src/services/event_manager/mod.rs)
async fn handle_component_interaction(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
if let Some(setup_id) = custom_id.strip_prefix("btn_confirm_") {
// Process with extracted setup_id
println!("Confirming action for setup: {}", setup_id);
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Action confirmed!")
.components(vec![])
)
).await?;
}
Ok(())
}
Module Structure
A module consists of three main parts:
- •Commands - Slash commands for user interaction
- •Event Handlers - Functions that respond to Discord events
- •Module Definition - Metadata and registration
Basic Module Layout
code
src/modules/my_module/
├── mod.rs # Module definition and registration
├── commands/ # Slash commands
│ └── mod.rs
└── events/ # Event handlers
└── mod.rs
Creating a New Module
Step 1: Create Module Directory Structure
bash
mkdir -p src/modules/my_module/commands src/modules/my_module/events
Step 2: Create Module Definition (mod.rs)
rust
// src/modules/my_module/mod.rs
pub mod commands;
pub mod events;
use crate::modules::{Module, ModuleDefinition};
pub fn module() -> Module {
Module {
definition: ModuleDefinition {
id: "my_module",
name_key: "module-my-module-name",
desc_key: "module-my-module-desc",
},
commands: vec![
commands::my_command(),
],
event_handlers: vec![events::handler],
}
}
Step 3: Register the Module
Add your module to src/modules/mod.rs:
rust
// src/modules/mod.rs
pub mod my_module; // Add this
pub fn get_modules() -> Vec<Module> {
vec![
// existing modules...
my_module::module(), // Add this
]
}
Step 4: Add Module Type to Database
Update src/db/entities/module_configs.rs to include your module:
rust
// In the ModuleType enum
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "module_type")]
pub enum ModuleType {
// existing types...
#[sea_orm(string_value = "MyModule")]
MyModule,
}
// Update the iter() method to include your module
pub fn module_type_from_string(s: &str) -> Option<ModuleType> {
match s {
// existing matches...
"MyModule" => Some(ModuleType::MyModule),
_ => None,
}
}
Creating Commands
Basic Command Structure
rust
// src/modules/my_module/commands/mod.rs
use crate::{Context, Error};
use poise::serenity_prelude as serenity;
/// Command description for help text
#[poise::command(
slash_command,
guild_only,
required_permissions = "ADMINISTRATOR",
ephemeral // Makes response ephemeral (only user sees it)
)]
pub async fn my_command(
ctx: Context<'_>,
#[description = "User to target"] user: serenity::User,
#[description = "Optional reason"] reason: Option<String>,
) -> Result<(), Error> {
ctx.defer_ephemeral().await?;
// Command logic here
let guild_id = ctx.guild_id().unwrap();
// Get localization for user
let l10n = ctx.l10n_user();
// Perform action
// ...
// ⚠️ If adding buttons/selects, DO NOT await their interactions
// See serenity-interactions skill for handling interaction handlers
ctx.send(
poise::CreateReply::default()
.content("Command executed successfully!")
)
.await?;
Ok(())
}
⚠️ Components and Interaction Handling
When adding buttons, selects, or modals to your commands:
rust
pub async fn interactive_command(ctx: Context<'_>) -> Result<(), Error> {
let setup_id = generate_unique_id();
// Send components with data in custom_ids
ctx.send(poise::CreateReply::default()
.components(vec![
serenity::CreateButton::new(format!("btn_confirm_{}", setup_id))
.label("Confirm")
.style(serenity::ButtonStyle::Success),
serenity::CreateButton::new(format!("btn_cancel_{}", setup_id))
.label("Cancel")
.style(serenity::ButtonStyle::Danger),
])
).await?;
// ✅ Command returns immediately - interactions handled separately
Ok(())
}
// Interactions handled in src/services/event_manager/mod.rs
// Add handler there to parse custom_ids like "btn_confirm_{}", "btn_cancel_{}"
Using Database Entities
rust
use crate::db::entities::module_configs::ModuleType;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryFilter, Set};
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let db = &ctx.data().db;
// Check if module is enabled for this guild
let config = crate::db::entities::module_configs::Entity::find()
.filter(crate::db::entities::module_configs::Column::GuildId.eq(guild_id.get() as i64))
.filter(crate::db::entities::module_configs::Column::ModuleType.eq(ModuleType::MyModule))
.one(db)
.await?;
if !config.map(|c| c.enabled).unwrap_or(false) {
ctx.send(poise::CreateReply::default()
.content("Module is not enabled for this server!")
).await?;
return Ok(());
}
// Your command logic...
Ok(())
}
Subcommands
rust
#[poise::command(
slash_command,
subcommands("add", "remove", "list")
)]
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(poise::CreateReply::default()
.content("Use a subcommand: /my_command add, /my_command remove, /my_command list")
).await?;
Ok(())
}
#[poise::command(slash_command)]
pub async fn add(ctx: Context<'_>) -> Result<(), Error> {
// Add logic
Ok(())
}
#[poise::command(slash_command)]
pub async fn remove(ctx: Context<'_>) -> Result<(), Error> {
// Remove logic
Ok(())
}
#[poise::command(slash_command)]
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
// List logic
Ok(())
}
Creating Event Handlers
Basic Event Handler
rust
// src/modules/my_module/events/mod.rs
use crate::modules::EventHandler;
use crate::{Data, Error};
use poise::serenity_prelude as serenity;
pub async fn handler(
ctx: &serenity::Context,
event: &serenity::FullEvent,
data: &Data,
) -> Result<(), Error> {
match event {
serenity::FullEvent::Message { new_message } => {
// Handle message events
handle_message(ctx, new_message, data).await?;
}
serenity::FullEvent::GuildMemberAddition { new_member } => {
// Handle member join events
handle_member_add(ctx, new_member, data).await?;
}
serenity::FullEvent::AuditLogEntryCreate { entry, .. } => {
// Handle audit log events
handle_audit_log(ctx, entry, data).await?;
}
_ => {}
}
Ok(())
}
async fn handle_message(
ctx: &serenity::Context,
message: &serenity::Message,
data: &Data,
) -> Result<(), Error> {
// Check if module is enabled
if let Some(guild_id) = message.guild_id {
let enabled = is_module_enabled(guild_id.get(), &data.db).await?;
if !enabled {
return Ok(());
}
// Your message handling logic here
}
Ok(())
}
async fn is_module_enabled(guild_id: u64, db: &sea_orm::DatabaseConnection) -> Result<bool, Error> {
use crate::db::entities::module_configs;
use sea_orm::{EntityTrait, QueryFilter};
let config = module_configs::Entity::find()
.filter(module_configs::Column::GuildId.eq(guild_id as i64))
.filter(module_configs::Column::ModuleType.eq(module_configs::ModuleType::MyModule))
.one(db)
.await?;
Ok(config.map(|c| c.enabled).unwrap_or(false))
}
Available Event Types
Common Discord events you can handle:
- •
Message { new_message }- New messages - •
MessageUpdate { old_if_available, new, event }- Message edits - •
MessageDelete { channel_id, deleted_message_id, guild_id }- Message deletions - •
GuildMemberAddition { new_member }- Members joining - •
GuildMemberRemoval { guild_id, user, member_data_if_available }- Members leaving - •
GuildMemberUpdate { old_if_available, new }- Member updates - •
AuditLogEntryCreate { entry, guild_id }- Audit log entries - •
VoiceStateUpdate { old, new }- Voice state changes - •
ChannelCreate { channel }- Channel creation - •
ChannelDelete { channel }- Channel deletion - •
RoleCreate { role }- Role creation - •
RoleDelete { role_id, guild_id }- Role deletion
Localization
Add localization keys to your locale files (in locales/):
ftl
module-my-module-name = My Module
module-my-module-desc = Description of what this module does
my-command-name = My Command
my-command-desc = Description of the command
my-command-success = Command executed successfully!
my-command-error = Failed to execute command: { $error }
Use localization in your commands:
rust
use fluent::FluentArgs;
let l10n = ctx.l10n_user();
let mut args = FluentArgs::new();
args.set("error", error_message);
ctx.send(poise::CreateReply::default()
.content(l10n.t("my-command-error", Some(&args)))
).await?;
Logging
Use the built-in logger service:
rust
use crate::services::logger::LogLevel;
use crate::db::entities::module_configs::ModuleType;
ctx.data().logger.log_context(
&ctx,
Some(ModuleType::MyModule),
LogLevel::Audit,
&l10n_guild.t("log-my-command-title", None),
&l10n_guild.t("log-my-command-desc", Some(&log_args)),
vec![
(&l10n_guild.t("log-field-user", None), format!("<@{}>", user.id)),
(&l10n_guild.t("log-field-reason", None), reason),
],
).await?;
Best Practices
- •⚠️ NEVER await interactions in commands: Commands must return immediately
- •⚠️ ALWAYS use separate handlers: Handle interactions in event handlers, not commands
- •⚠️ ALWAYS use custom_id parsing: Transport data via custom_ids, not awaiting
- •Check module enablement: Always check if the module is enabled before performing actions
- •Use localization: Make all user-facing text translatable
- •Handle errors gracefully: Provide meaningful error messages to users
- •Log important actions: Use the logger service for audit trails
- •Use
defer_ephemeral(): For long-running commands to provide immediate feedback - •Validate permissions: Ensure users have required permissions before actions
- •Use transactions: When making multiple database changes, wrap in a transaction
Testing Your Module
- •Build the project:
cargo build --release - •Run the bot:
cargo run --release - •Register commands (first time):
cargo run --release -- --publish GUILD_ID - •Test commands in Discord
- •Check logs for errors
- •Test interaction handlers (buttons, selects, modals)
Examples
See existing modules for reference:
- •
src/modules/moderation_protection/- Moderation commands - •
src/modules/logging/- Logging events - •
src/modules/role_protection/- Role protection events - •
src/services/setup/mod.rs- Complete example of multi-step interactions with custom_id parsing