Claude Code Plugins

Community-maintained marketplace

Feedback

serenity-discord-bot

@ocn/zk-activity
6
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

// 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();

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);
        // 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
            }
            _ => {}
        }
    }
}

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>,
    ) {
        // 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();
    }
}

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);  // false = not inline
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();

    // 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
}

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

// 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?;

Error Handling for Discord Operations

Pattern for Message Sending

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(())
}

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;
        // Handle selection...

        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  // Privileged!
    | 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