Skip to content

Commit

Permalink
Add an example for message components (#1576)
Browse files Browse the repository at this point in the history
  • Loading branch information
pascalharp committed Nov 21, 2021
1 parent df47df1 commit ee00e92
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Expand Up @@ -183,3 +183,5 @@ jobs:
run: cargo build -p e15_simple_dashboard
- name: 'Build example 16'
run: cargo build -p e16_sqlite_database
- name: 'Build example 17'
run: cargo build -p e17_message_components
3 changes: 2 additions & 1 deletion Cargo.toml
Expand Up @@ -29,7 +29,8 @@ members = [
"examples/e13_parallel_loops",
"examples/e14_slash_commands",
"examples/e15_simple_dashboard",
"examples/e16_sqlite_database"
"examples/e16_sqlite_database",
"examples/e17_message_components"
]

[dependencies]
Expand Down
19 changes: 19 additions & 0 deletions Makefile.toml
Expand Up @@ -343,3 +343,22 @@ args = ["make", "run_example", "e16_sqlite_database"]
[tasks.dev_build_16]
command = "cargo"
args = ["make", "build_example", "e16_sqlite_database"]

[tasks.17]
alias = "run_17"

[tasks.run_17]
command = "cargo"
args = ["make", "run_example_release", "e17_message_components"]

[tasks.build_17]
command = "cargo"
args = ["make", "build_example_release", "e17_message_components"]

[tasks.dev_run_17]
command = "cargo"
args = ["make", "run_example", "e17_message_components"]

[tasks.dev_build_17]
command = "cargo"
args = ["make", "build_example", "e17_message_components"]
1 change: 1 addition & 0 deletions examples/README.md
Expand Up @@ -45,6 +45,7 @@ To run an example, you have various options:
14 => Slash Commands: How to use the low level slash command API.
15 => Simple Dashboard: A simple dashboard to control and monitor the bot with `rillrate`.
16 => SQLite Database: How to run an embedded SQLite database alongside the bot using SQLx
17 => Message Components: How to structure and use buttons and select menus
```

2. Manually running:
Expand Down
10 changes: 10 additions & 0 deletions examples/e17_message_components/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "e17_message_components"
version = "0.1.0"
authors = ["my name <my@email.address>"]
edition = "2018"

[dependencies]
serenity = { path = "../../", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "unstable_discord_api", "collector"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
dotenv = { version = "0.15.0" }
13 changes: 13 additions & 0 deletions examples/e17_message_components/Makefile.toml
@@ -0,0 +1,13 @@
extend = "../../Makefile.toml"

[tasks.examples_build]
alias = "build"

[tasks.examples_build_release]
alias = "build_release"

[tasks.examples_run]
alias = "run"

[tasks.examples_run_release]
alias = "run_release"
270 changes: 270 additions & 0 deletions examples/e17_message_components/src/main.rs
@@ -0,0 +1,270 @@
use std::{
env,
error::Error as StdError,
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
time::Duration,
};

use dotenv::dotenv;
use serenity::{
async_trait,
builder::{CreateActionRow, CreateButton, CreateSelectMenu, CreateSelectMenuOption},
client::{Context, EventHandler},
futures::StreamExt,
model::{
channel::{Message, ReactionType},
interactions::{
message_component::ButtonStyle,
InteractionApplicationCommandCallbackDataFlags,
InteractionResponseType,
},
},
Client,
};

#[derive(Debug)]
enum Animal {
Cat,
Dog,
Horse,
Alpaca,
}

#[derive(Debug)]
struct ParseComponentError(String);

impl Display for ParseComponentError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "Failed to parse {} as component", self.0)
}
}

impl StdError for ParseComponentError {}

impl Display for Animal {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
Self::Cat => write!(f, "Cat"),
Self::Dog => write!(f, "Dog"),
Self::Horse => write!(f, "Horse"),
Self::Alpaca => write!(f, "Alpaca"),
}
}
}

impl FromStr for Animal {
type Err = ParseComponentError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cat" => Ok(Animal::Cat),
"dog" => Ok(Animal::Dog),
"horse" => Ok(Animal::Horse),
"alpaca" => Ok(Animal::Alpaca),
_ => Err(ParseComponentError(s.to_string())),
}
}
}

impl Animal {
fn emoji(&self) -> &str {
match self {
Self::Cat => "🐈",
Self::Dog => "🐕",
Self::Horse => "🐎",
Self::Alpaca => "🦙",
}
}

fn menu_option(&self) -> CreateSelectMenuOption {
let mut opt = CreateSelectMenuOption::default();
// This is what will be shown to the user
opt.label(format!("{} {}", self.emoji(), self));
// This is used to identify the selected value
opt.value(self.to_string().to_ascii_lowercase());
opt
}

fn select_menu() -> CreateSelectMenu {
let mut menu = CreateSelectMenu::default();
menu.custom_id("animal_select");
menu.placeholder("No animal selected");
menu.options(|f| {
f.add_option(Self::Cat.menu_option());
f.add_option(Self::Dog.menu_option());
f.add_option(Self::Horse.menu_option());
f.add_option(Self::Alpaca.menu_option())
});
menu
}

fn action_row() -> CreateActionRow {
let mut ar = CreateActionRow::default();
// A select menu must be the only thing in an action row!
ar.add_select_menu(Self::select_menu());
ar
}
}

#[derive(Debug)]
enum Sound {
Meow,
Woof,
Neigh,
Honk,
}

impl Display for Sound {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
Self::Meow => write!(f, "meow"),
Self::Woof => write!(f, "woof"),
Self::Neigh => write!(f, "neigh"),
Self::Honk => write!(f, "hoooooooonk"),
}
}
}

impl Sound {
fn emoji(&self) -> &str {
match self {
Self::Meow => Animal::Cat.emoji(),
Self::Woof => Animal::Dog.emoji(),
Self::Neigh => Animal::Horse.emoji(),
Self::Honk => Animal::Alpaca.emoji(),
}
}

fn button(&self) -> CreateButton {
let mut b = CreateButton::default();
b.custom_id(self.to_string().to_ascii_lowercase());
b.emoji(ReactionType::Unicode(self.emoji().to_string()));
b.label(self);
b.style(ButtonStyle::Primary);
b
}

fn action_row() -> CreateActionRow {
let mut ar = CreateActionRow::default();
// We can add up to 5 buttons per action row
ar.add_button(Sound::Meow.button());
ar.add_button(Sound::Woof.button());
ar.add_button(Sound::Neigh.button());
ar.add_button(Sound::Honk.button());
ar
}
}

impl FromStr for Sound {
type Err = ParseComponentError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"meow" => Ok(Sound::Meow),
"woof" => Ok(Sound::Woof),
"neigh" => Ok(Sound::Neigh),
"hoooooooonk" => Ok(Sound::Honk),
_ => Err(ParseComponentError(s.to_string())),
}
}
}

struct Handler;

#[async_trait]
impl EventHandler for Handler {
async fn message(&self, ctx: Context, msg: Message) {
if msg.content != "animal" {
return;
}

// Ask the user for its favorite animal
let m = msg
.channel_id
.send_message(&ctx, |m| {
m.content("Please select your favorite animal");
m.components(|c| c.add_action_row(Animal::action_row()));
m
})
.await
.unwrap();

// Wait for the user to make a selection
let mci =
match m.await_component_interaction(&ctx).timeout(Duration::from_secs(60 * 3)).await {
Some(ci) => ci,
None => {
m.reply(&ctx, "Timed out").await.unwrap();
return;
},
};

// data.custom_id contains the id of the component (here "animal_select")
// and should be used to identify if a message has multiple components.
// data.values contains the selected values from the menu
let animal = Animal::from_str(&mci.data.values.get(0).unwrap()).unwrap();

// Acknowledge the interaction and edit the message
mci.create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage);
r.interaction_response_data(|d| {
d.content(format!("You chose: **{}**\nNow choose a sound!", animal));
d.components(|c| c.add_action_row(Sound::action_row()))
})
})
.await
.unwrap();

// Wait for multiple interactions

let mut cib =
m.await_component_interactions(&ctx).timeout(Duration::from_secs(60 * 3)).await;

while let Some(mci) = cib.next().await {
let sound = Sound::from_str(&mci.data.custom_id).unwrap();
// Acknowledge the interaction and send a reply
mci.create_interaction_response(&ctx, |r| {
// This time we dont edit the message but reply to it
r.kind(InteractionResponseType::ChannelMessageWithSource);
r.interaction_response_data(|d| {
// Make the message hidden for other users
d.flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
d.content(format!("The **{}** says __{}__", animal, sound))
})
})
.await
.unwrap();
}

// Delete the orig message or there will be dangling components
m.delete(&ctx).await.unwrap()
}
}

#[tokio::main]
async fn main() {
dotenv().ok();
// Configure the client with your Discord bot token in the environment.
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");

// The Application Id is usually the Bot User Id. It is needed for components
let application_id: u64 = env::var("APPLICATION_ID")
.expect("Expected an application id in the environment")
.parse()
.expect("application id is not a valid id");

// Build our client.
let mut client = Client::builder(token)
.event_handler(Handler)
.application_id(application_id)
.await
.expect("Error creating client");

// Finally, start a single shard, and start listening to events.
// Shards will automatically attempt to reconnect, and will perform
// exponential backoff until it reconnects.
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
}
}

0 comments on commit ee00e92

Please sign in to comment.