返回 Skill 列表
extension
分类: 开发与工程无需 API Key

serenity-discord-bot

使用Serenity 0.11进行Discord机器人开发。涵盖事件处理器、斜杠命令、嵌入、交互、用于共享状态的TypeMapKey以及消息发送模式。在创建Discord命令、构建嵌入、处理交互或与Discord API工作时使用。

person作者: jakexiaohubgithub

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