| name | serenity-interactions |
| description | Guide for handling Discord interactions (buttons, selects, modals) in the Open Guard bot Use when this capability is needed. |
| metadata | {"author":"erdemgksl"} |
Serenity Interactions
What this skill does
Provides guidance on handling Discord interactions including buttons, select menus, modals, and other interactive components in the Open Guard bot.
When to use
Use this skill when you need to:
- Create interactive buttons in messages
- Handle button clicks
- Create select menus (dropdowns)
- Handle select menu selections
- Create and handle modals
- Handle file uploads from modals
- Update or respond to interactions
⚠️ CRITICAL: Do Not Await Interactions
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_id parsing
The Pattern
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(poise::CreateReply::default()
.components(vec![
serenity::CreateButton::new("btn_click")
.label("Click Me!")
.style(serenity::ButtonStyle::Primary),
])
).await?;
let interaction = ;
Ok(())
}
pub async fn my_command(ctx: Context<'_>) -> Result<(), Error> {
let setup_id = generate_unique_id();
ctx.send(poise::CreateReply::default()
.content("Click the button!")
.components(vec![
serenity::CreateButton::new(format!("btn_click_{}", setup_id))
.label("Click Me!")
.style(serenity::ButtonStyle::Primary),
])
).await?;
Ok(())
}
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_click_") {
println!("Processing button for setup: {}", setup_id);
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Button clicked!")
.components(vec![])
)
).await?;
}
Ok(())
}
Interaction Types
- Component Interactions - Buttons and select menus
- Modal Submissions - Form submissions
- Application Commands - Slash commands (handled by Poise)
Setting Up Interaction Handlers
Event Handler Integration
The main interaction handler is in src/services/event_manager/mod.rs:
use crate::services::event_manager::Handler;
.event_handler(Arc::new(services::event_manager::Handler::new()))
Handling Interactions in Events
use poise::serenity_prelude as serenity;
async fn handle_interaction(
ctx: &serenity::Context,
interaction: &serenity::Interaction,
data: &Data,
) -> Result<(), Error> {
match interaction {
serenity::Interaction::Component(component) => {
handle_component_interaction(ctx, component, data).await?;
}
serenity::Interaction::Modal(modal) => {
handle_modal_submission(ctx, modal, data).await?;
}
_ => {}
}
Ok(())
}
Buttons
Creating Buttons
use poise::serenity_prelude as serenity;
pub async fn send_with_buttons(ctx: Context<'_>) -> Result<(), Error> {
ctx.send(poise::CreateReply::default()
.content("Click a button!")
.components(vec![
serenity::CreateActionRow::Buttons(vec![
serenity::CreateButton::new("btn_primary")
.label("Primary")
.style(serenity::ButtonStyle::Primary),
serenity::CreateButton::new("btn_secondary")
.label("Secondary")
.style(serenity::ButtonStyle::Secondary),
serenity::CreateButton::new("btn_success")
.label("Success")
.style(serenity::ButtonStyle::Success),
serenity::CreateButton::new("btn_danger")
.label("Danger")
.(serenity::ButtonStyle::Danger),
serenity::CreateButton::()
.()
.(serenity::ButtonStyle::Link)
.(),
])
])
).?;
(())
}
Button with Emoji
serenity::CreateButton::new("btn_emoji")
.label("Click Me!")
.emoji(serenity::ReactionType::Unicode("🎉".to_string()))
.style(serenity::ButtonStyle::Primary)
Disabled Button
serenity::CreateButton::new("btn_disabled")
.label("Disabled")
.style(serenity::ButtonStyle::Primary)
.disabled(true)
Handling Button Clicks
async fn handle_component_interaction(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
let guild_id = interaction.guild_id;
if custom_id.starts_with("btn_") {
match custom_id.as_str() {
"btn_primary" => {
handle_primary_button(ctx, interaction, data).await?;
}
"btn_danger" => {
handle_danger_button(ctx, interaction, data).await?;
}
_ => {}
}
}
Ok(())
}
async fn handle_primary_button(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
interaction
.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("You clicked the primary button!")
)
)
.await?;
Ok(())
}
Transporting Data via Custom IDs
Use custom_ids to pass data between command and handler:
pub async fn start_process(ctx: Context<'_>) -> Result<(), Error> {
let setup_id = "setup_12345";
let user_id = ctx.author().id.get();
ctx.send(poise::CreateReply::default()
.components(vec![
serenity::CreateButton::new(format!("btn_next_{}_{}", setup_id, user_id))
.label("Next Step")
.style(serenity::ButtonStyle::Primary),
])
).await?;
Ok(())
}
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(rest) = custom_id.strip_prefix("btn_next_") {
let parts: Vec<&str> = rest.().();
= parts[];
: = parts[].()?;
(, setup_id, user_id);
}
(())
}
Response Types for Interactions
⚠️ Important: Acknowledge-Then-Edit Pattern for Menus
When handling select menus (dropdowns), the recommended pattern is:
- Acknowledge the interaction (shows "thinking" state)
- Edit the message later with the new content
This is especially useful when you need to:
- Show a loading state while processing
- Update the message based on selection
- Keep the message in place while changing its content
async fn handle_select_menu(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
if custom_id == "menu_id" {
let values = match &interaction.data.kind {
serenity::ComponentInteractionDataKind::StringSelect { values } => values,
_ => return Ok(()),
};
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::Acknowledge
).await?;
let result = process_selection(values).await?;
interaction.edit_response(
ctx,
serenity::EditInteractionResponse::new()
.content(format!("Selected: {}", result))
.components(vec![])
).await?;
}
Ok(())
}
Why use this pattern?
- Acknowledging prevents the interaction from timing out
- Users see immediate feedback (loading state)
- Editing the message updates it in place (no new message created)
- Works seamlessly with message interactions
Response Types Summary
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::Acknowledge
).await?;
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Updated message!")
.components(vec![])
)
).await?;
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::Message(
serenity::CreateInteractionResponseMessage::new()
.content("New message!")
)
).await?
Editing Messages After Acknowledging
When you acknowledge an interaction without a reply, you can edit the original message:
async fn handle_select_with_edit(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
if custom_id == "menu_select" {
let values = match &interaction.data.kind {
serenity::ComponentInteractionDataKind::StringSelect { values } => values,
_ => return Ok(()),
};
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::Acknowledge
).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let message = interaction.message.as_ref().unwrap();
let mut builder = message.to_builder();
builder
.content(format!("You selected: {}", values.join(", ")))
.components(vec![]);
message.edit(ctx, builder).?;
}
(())
}
Key points:
- Use
create_response(Acknowledge) to prevent timeout
- Access original message via
interaction.message
- Use
message.edit(ctx, builder) to update content
- This keeps the original message, just changes its content
When to use acknowledge-then-edit:
- Select menus where you need to process the selection
- When showing a loading state is useful
- When you want to update the existing message in place
- When you don't want to create a new message
Select Menus
String Select (Custom Options)
ctx.send(poise::CreateReply::default()
.content("Select an option!")
.components(vec![
serenity::CreateActionRow::SelectMenu(
serenity::CreateSelectMenu::new(
"string_select_id",
serenity::CreateSelectMenuKind::String {
options: vec![
serenity::CreateSelectMenuOption::new("Option 1", "opt1"),
serenity::CreateSelectMenuOption::new("Option 2", "opt2")
.description("This is option 2"),
serenity::CreateSelectMenuOption::new("Option 3", "opt3")
.emoji(serenity::ReactionType::Unicode("⭐".to_string())),
]
}
)
.placeholder("Choose an option")
.min_values(1)
.max_values(1)
)
])
).await?;
User Select
serenity::CreateActionRow::SelectMenu(
serenity::CreateSelectMenu::new(
"user_select_id",
serenity::CreateSelectMenuKind::User {
default_users: None,
}
)
.placeholder("Select users")
.min_values(1)
.max_values(3)
)
Role Select
serenity::CreateActionRow::SelectMenu(
serenity::CreateSelectMenu::new(
"role_select_id",
serenity::CreateSelectMenuKind::Role {
default_roles: None,
}
)
.placeholder("Select roles")
.min_values(1)
.max_values(1)
)
Channel Select
use poise::serenity_prelude as serenity;
use std::borrow::Cow;
serenity::CreateActionRow::SelectMenu(
serenity::CreateSelectMenu::new(
"channel_select_id",
serenity::CreateSelectMenuKind::Channel {
channel_types: Some(Cow::Borrowed(&[
serenity::ChannelType::Text,
serenity::ChannelType::Voice,
serenity::ChannelType::Category,
])),
default_channels: None,
}
)
.placeholder("Select channels")
)
Mentionable Select (Users + Roles)
serenity::CreateActionRow::SelectMenu(
serenity::CreateSelectMenu::new(
"mentionable_select_id",
serenity::CreateSelectMenuKind::Mentionable {
default_users: None,
default_roles: None,
}
)
.placeholder("Select users or roles")
)
Handling Select Menu Selection
async fn handle_component_interaction(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
if custom_id == "string_select_id" {
let values = match &interaction.data.kind {
serenity::ComponentInteractionDataKind::StringSelect { values } => values,
_ => return Ok(()),
};
for value in values {
println!("Selected: {}", value);
}
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content(format!("You selected: {}", values.join(", ")))
.components(vec![])
)
).await?;
}
Ok(())
}
Modals
Creating a Modal
use poise::serenity_prelude as serenity;
use std::borrow::Cow;
async fn show_modal(
ctx: &serenity::Context,
interaction: &serenity::ComponentInteraction,
) -> Result<(), Error> {
let modal = serenity::CreateModal::new()
.custom_id("feedback_modal")
.title("Feedback Form")
.components(vec![
serenity::CreateLabel::input_text(
"Your Name",
serenity::CreateInputText::new(
serenity::InputTextStyle::Short,
"name_field"
)
.placeholder("Enter your name")
.required(true)
),
serenity::CreateLabel::input_text(
"Feedback",
serenity::CreateInputText::new(
serenity::InputTextStyle::Paragraph,
"feedback_field"
)
.placeholder("Enter your feedback here...")
.min_length(10)
.max_length(1000)
.required()
),
]);
interaction.(
ctx,
serenity::CreateInteractionResponse::(modal)
).?;
(())
}
Modal with Select Menu
let modal = serenity::CreateModal::new()
.custom_id("rating_modal")
.title("Rate Your Experience")
.components(vec![
serenity::CreateLabel::select_menu(
"Rating",
serenity::CreateSelectMenu::new(
"rating_select",
serenity::CreateSelectMenuKind::String {
options: Cow::Borrowed(&[
serenity::CreateSelectMenuOption::new("⭐ Poor", "1"),
serenity::CreateSelectMenuOption::new("⭐⭐ Fair", "2"),
serenity::CreateSelectMenuOption::new("⭐⭐⭐ Good", "3"),
serenity::CreateSelectMenuOption::new("⭐⭐⭐⭐ Very Good", "4"),
serenity::CreateSelectMenuOption::new("⭐⭐⭐⭐⭐ Excellent", "5"),
])
}
)
.placeholder("Select a rating")
.required(true)
),
serenity::CreateLabel::input_text(
"Comments",
serenity::CreateInputText::new(
serenity::InputTextStyle::Paragraph,
"comments_field"
)
.()
.()
),
]);
Modal with File Upload
let modal = serenity::CreateModal::new()
.custom_id("file_upload_modal")
.title("Upload Files")
.components(vec![
serenity::CreateLabel::file_upload(
"Screenshots",
serenity::CreateFileUpload::new("screenshot_upload")
.min_values(1)
.max_values(5)
.required(true)
),
serenity::CreateLabel::input_text(
"Description",
serenity::CreateInputText::new(
serenity::InputTextStyle::Short,
"description_field"
)
.placeholder("Describe the files...")
.required(true)
),
]);
Handling Modal Submissions
async fn handle_modal_submission(
ctx: &serenity::Context,
interaction: &serenity::ModalSubmitInteraction,
data: &Data,
) -> Result<(), Error> {
let custom_id = &interaction.data.custom_id;
if custom_id == "feedback_modal" {
let mut name = String::new();
let mut feedback = String::new();
for component in &interaction.data.components {
if let serenity::ActionRowComponent::InputText(input) = component {
match input.custom_id.as_str() {
"name_field" => name = input.value.clone(),
"feedback_field" => feedback = input.value.clone(),
_ => {}
}
}
}
println!("Name: {}, Feedback: {}", name, feedback);
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.()
.([])
)
).?;
}
(())
}
Interaction Context
Getting User Information
let user = &interaction.user;
let member = interaction.member.as_ref();
let guild_id = interaction.guild_id;
let channel_id = interaction.channel_id;
let locale = &interaction.locale;
Getting Locale-Specific Strings
use crate::services::localization::L10nProxy;
let l10n = L10nProxy {
manager: data.l10n.clone(),
locale: interaction.locale.to_string(),
};
let localized_text = l10n.t("message-key", None);
Updating Components
Update Button State
let disabled_buttons = vec![
serenity::CreateActionRow::Buttons(vec![
serenity::CreateButton::new("btn_click_me")
.label("Already Clicked!")
.style(serenity::ButtonStyle::Success)
.disabled(true),
])
];
interaction.create_response(
ctx,
serenity::CreateInteractionResponse::UpdateMessage(
serenity::CreateInteractionResponseMessage::new()
.content("Button clicked!")
.components(disabled_buttons)
)
).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
- ⚠️ For select menus, use acknowledge-then-edit: Acknowledge first, then edit message with results
- Use descriptive custom_ids: Make them unique and meaningful
- Handle all interaction types: Don't leave interactions unhandled
- Use proper response types: Choose UpdateMessage vs. Message vs. Acknowledge appropriately
- Validate user input: Check permissions and data before processing
- Use localization: Make all interaction responses translatable
- Clean up components: Remove buttons/selects when no longer needed
- Use timeouts: Consider using collectors for time-limited interactions
- Log important actions: Use the logger service for audit trails
Example: Complete Setup Workflow
See src/services/setup/mod.rs for a complete example of:
- Multi-step setup process
- Select menus for configuration
- State management across interactions
- Dynamic component updates
- Data transport via custom_id parsing
Resources
- See
discord-modals skill for modal-specific guidance
Converted and distributed by TomeVault — claim your Tome and manage your conversions.