| name | serenity-discord-bot |
| description | Discord bot development with Serenity 0.11. Covers event handlers, slash commands, embeds, interactions, TypeMapKey for shared state, and message sending patterns. Use when creating Discord commands, building embeds, handling interactions, or working with the Discord API. |
Serenity Discord Bot Guidelines
Purpose
Development patterns for Discord bots using Serenity 0.11, specifically tailored to this project's architecture.
When to Use
- Creating or modifying slash commands
- Building Discord embeds
- Handling Discord events
- Working with interactions
- Sending messages to channels
- Managing bot state
Project Architecture
Key Files
src/
discord_bot.rs # EventHandler, message sending, embed building
commands.rs # Command trait, helper functions
commands/
subscribe.rs # /subscribe command
unsubscribe.rs # /unsubscribe command
diag.rs # /diag command
...
State Management
The bot uses TypeMapKey to store shared state in Serenity's context:
pub struct AppStateContainer;
impl TypeMapKey for AppStateContainer {
type Value = Arc<AppState>;
}
pub struct CommandMap;
impl TypeMapKey for CommandMap {
type Value = Arc<HashMap<String, Box<dyn Command>>>;
}
let data = ctx.data.read().await;
let app_state = data.get::<AppStateContainer>().unwrap();
Event Handler
Basic Structure
use serenity::async_trait;
use serenity::prelude::*;
use serenity::model::gateway::Ready;
use serenity::model::prelude::Interaction;
pub struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, data_about_bot: Ready) {
info!("Discord bot {} is connected!", data_about_bot.user.name);
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(command) => {
}
Interaction::MessageComponent(component) => {
}
_ => {}
}
}
}
Registering Global Commands
async fn ready(&self, ctx: Context, _: Ready) {
let data = ctx.data.read().await;
let command_map = data.get::<CommandMap>().unwrap();
serenity::model::application::command::Command::set_global_application_commands(
&ctx.http,
|commands| {
for cmd in command_map.values() {
commands.create_application_command(|c| cmd.register(c));
}
commands
},
).await.expect("Failed to register commands");
}
Slash Commands
Command Trait Pattern
use serenity::async_trait;
use serenity::builder::CreateApplicationCommand;
use serenity::model::prelude::interaction::application_command::ApplicationCommandInteraction;
#[async_trait]
pub trait Command: Send + Sync {
fn name(&self) -> String;
fn register<'a>(
&self,
command: &'a mut CreateApplicationCommand,
) -> &'a mut CreateApplicationCommand;
async fn execute(
&self,
ctx: &Context,
command: &ApplicationCommandInteraction,
app_state: &Arc<AppState>,
);
}
Implementing a Command
pub struct MyCommand;
#[async_trait]
impl Command for MyCommand {
fn name(&self) -> String {
"mycommand".to_string()
}
fn register<'a>(
&self,
command: &'a mut CreateApplicationCommand,
) -> &'a mut CreateApplicationCommand {
command
.name("mycommand")
.description("Description of my command")
.create_option(|opt| {
opt.name("required_arg")
.description("A required argument")
.kind(CommandOptionType::String)
.required(true)
})
.create_option(|opt| {
opt.name("optional_arg")
.description("An optional argument")
.kind(CommandOptionType::Integer)
.required(false)
})
}
async fn execute(
&self,
ctx: &Context,
command: &ApplicationCommandInteraction,
app_state: &Arc<AppState>,
) {
let required = get_option_value(&command.data.options, "required_arg")
.and_then(|v| match v {
CommandDataOptionValue::String(s) => Some(s.clone()),
_ => None,
});
command.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| m.content("Response!"))
}).await.unwrap();
}
}
Helper for Extracting Options
pub fn get_option_value<'a>(
options: &'a [CommandDataOption],
name: &str,
) -> Option<&'a CommandDataOptionValue> {
options
.iter()
.find(|opt| opt.name == name)
.and_then(|opt| opt.resolved.as_ref())
}
Building Embeds
Basic Embed
use serenity::builder::CreateEmbed;
use serenity::utils::Colour;
let mut embed = CreateEmbed::default();
embed.title("Embed Title");
embed.url("https://example.com");
embed.description("Embed description text");
embed.color(Colour::DARK_GREEN);
embed.thumbnail("https://example.com/image.png");
embed.footer(|f| f.text("Footer text"));
embed.timestamp(chrono::Utc::now().to_rfc3339());
Embed with Fields
embed.field("Field Name", "Field value", false);
embed.field("Inline 1", "Value", true);
embed.field("Inline 2", "Value", true);
Embed with Author
embed.author(|a| {
a.name("Author Name")
.url("https://author.link")
.icon_url("https://icon.url")
});
This Project's Embed Pattern
async fn build_killmail_embed(
app_state: &Arc<AppState>,
zk_data: &ZkData,
) -> CreateEmbed {
let mut embed = CreateEmbed::default();
embed.title(format!("`{}` destroyed", victim_ship_name));
embed.url(format!("https://zkillboard.com/kill/{}/", kill_id));
embed.author(|a| {
a.name(author_text)
.url(related_br_url)
.icon_url(alliance_icon_url)
});
embed.thumbnail(ship_icon_url);
embed.color(Colour::DARK_GREEN);
embed.field("Attackers", attacker_info, false);
embed.footer(|f| f.text(format!("Value: {}", value_str)));
embed
}
Sending Messages
To a Channel
use serenity::model::prelude::ChannelId;
let channel = ChannelId(channel_id_u64);
channel.send_message(&ctx.http, |m| {
m.content("Message content")
.set_embed(embed)
}).await?;
With Optional Ping
channel.send_message(http, |m| {
if should_ping {
m.content("@everyone")
} else {
m
}
.set_embed(embed)
}).await?;
Responding to Interactions
command.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| {
m.content("Processing...")
.ephemeral(true)
})
}).await?;
command.create_followup_message(&ctx.http, |m| {
m.content("Follow-up message")
}).await?;
command.edit_original_interaction_response(&ctx.http, |m| {
m.content("Updated response")
}).await?;
Error Handling for Discord Operations
Pattern for Message Sending
pub enum KillmailSendError {
CleanupChannel(serenity::Error),
Other(Box<dyn std::error::Error + Send + Sync>),
}
pub async fn send_message() -> Result<(), KillmailSendError> {
let result = channel.send_message(http, |m| m.set_embed(embed)).await;
if let Err(e) = result {
if let serenity::Error::Http(http_err) = &e {
match &**http_err {
Error::UnsuccessfulRequest(resp) => match resp.status_code {
StatusCode::FORBIDDEN => {
return Err(KillmailSendError::CleanupChannel(e));
}
StatusCode::NOT_FOUND => {
return Err(KillmailSendError::CleanupChannel(e));
}
_ => {}
},
_ => {}
}
}
return Err(KillmailSendError::Other(Box::new(e)));
}
Ok(())
}
Component Interactions (Buttons, Select Menus)
Creating a Select Menu
command.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| {
m.content("Select an option:")
.components(|c| {
c.create_action_row(|row| {
row.create_select_menu(|menu| {
menu.custom_id("my_select_menu")
.placeholder("Choose...")
.options(|opts| {
opts.create_option(|o| {
o.label("Option 1")
.value("opt1")
.description("Description")
})
})
})
})
})
})
}).await?;
Handling Component Interaction
Interaction::MessageComponent(component) => {
let custom_id = &component.data.custom_id;
if custom_id.starts_with("my_select_menu") {
let selected = &component.data.values;
component.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| {
m.content("Selection received!")
.ephemeral(true)
})
}).await?;
}
}
GatewayIntents
let intents = GatewayIntents::non_privileged()
| GatewayIntents::GUILDS
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT
| GatewayIntents::GUILD_INTEGRATIONS;
let mut client = Client::builder(&token, intents)
.event_handler(Handler)
.await?;
Quick Reference
| Task | Method |
|---|
| Get shared state | ctx.data.read().await.get::<T>() |
| Send to channel | ChannelId(id).send_message(&http, |m| ...) |
| Reply to command | command.create_interaction_response(...) |
| Build embed | CreateEmbed::default() then chain methods |
| Make ephemeral | .ephemeral(true) in response data |
Reference Files