Discord bot development with Serenity 0.11. Covers event handlers, slash commands, embeds, interactions, TypeMapKey for shared state, and message sending patterns...
Development patterns for Discord bots using Serenity 0.11, specifically tailored to this project's architecture.
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
...
The bot uses TypeMapKey to store shared state in Serenity's context:
// In lib.rs
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>>>;
}
// Usage in handlers
let data = ctx.data.read().await;
let app_state = data.get::<AppStateContainer>().unwrap();
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);
// Register commands here
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(command) => {
// Handle slash commands
}
Interaction::MessageComponent(component) => {
// Handle button clicks, select menus
}
_ => {}
}
}
}
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");
}
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>,
);
}
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>,
) {
// Extract options
let required = get_option_value(&command.data.options, "required_arg")
.and_then(|v| match v {
CommandDataOptionValue::String(s) => Some(s.clone()),
_ => None,
});
// Respond
command.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| m.content("Response!"))
}).await.unwrap();
}
}
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())
}
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.field("Field Name", "Field value", false); // false = not inline
embed.field("Inline 1", "Value", true);
embed.field("Inline 2", "Value", true);
embed.author(|a| {
a.name("Author Name")
.url("https://author.link")
.icon_url("https://icon.url")
});
async fn build_killmail_embed(
app_state: &Arc<AppState>,
zk_data: &ZkData,
) -> CreateEmbed {
let mut embed = CreateEmbed::default();
// Build dynamically based on data
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
}
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?;
channel.send_message(http, |m| {
if should_ping {
m.content("@everyone")
} else {
m
}
.set_embed(embed)
}).await?;
// Initial response
command.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| {
m.content("Processing...")
.ephemeral(true) // Only visible to user
})
}).await?;
// Follow-up (can be used multiple times)
command.create_followup_message(&ctx.http, |m| {
m.content("Follow-up message")
}).await?;
// Edit original response
command.edit_original_interaction_response(&ctx.http, |m| {
m.content("Updated response")
}).await?;
pub enum KillmailSendError {
CleanupChannel(serenity::Error), // Channel deleted/no access
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 => {
// Bot removed or no permission
return Err(KillmailSendError::CleanupChannel(e));
}
StatusCode::NOT_FOUND => {
// Channel deleted
return Err(KillmailSendError::CleanupChannel(e));
}
_ => {}
},
_ => {}
}
}
return Err(KillmailSendError::Other(Box::new(e)));
}
Ok(())
}
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?;
Interaction::MessageComponent(component) => {
let custom_id = &component.data.custom_id;
if custom_id.starts_with("my_select_menu") {
let selected = &component.data.values;
// Handle selection...
component.create_interaction_response(&ctx.http, |r| {
r.interaction_response_data(|m| {
m.content("Selection received!")
.ephemeral(true)
})
}).await?;
}
}
let intents = GatewayIntents::non_privileged()
| GatewayIntents::GUILDS
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT // Privileged!
| GatewayIntents::GUILD_INTEGRATIONS;
let mut client = Client::builder(&token, intents)
.event_handler(Handler)
.await?;
| 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 |