Skip to content

Commit 2bb8508

Browse files
committed
chore(commands): Add a FAQ command
1 parent 0b9b14b commit 2bb8508

6 files changed

Lines changed: 162 additions & 13 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/target
22
.idea
33
.env
4-
magnolia.cfg.yml
4+
magnolia.cfg.yml
5+
magnolia.cfg.*.yml

bot/src/commands/devforum_self_role.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub(crate) struct DevForumSelfRole<'a> {
2323

2424
#[async_trait]
2525
impl CommandHandler for DevForumSelfRole<'_> {
26-
fn model() -> anyhow::Result<Command> {
26+
fn model(_ctx: Option<crate::Context>) -> anyhow::Result<Command> {
2727
Ok(CommandBuilder::new(
2828
"devforum-self-role",
2929
"Send an info embed with a button to self-update DevForum roles.",

bot/src/commands/faq.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use anyhow::Context;
2+
use async_trait::async_trait;
3+
use builders::command_option::CommandOptionBuilder;
4+
use twilight_model::application::command::{Command, CommandOptionType, CommandType};
5+
use twilight_model::application::interaction::application_command::CommandOptionValue;
6+
use twilight_model::application::interaction::{
7+
Interaction, InteractionContextType, InteractionData,
8+
};
9+
use twilight_model::guild::Permissions;
10+
use twilight_model::http::interaction::{InteractionResponse, InteractionResponseType};
11+
use twilight_model::oauth::ApplicationIntegrationType;
12+
use twilight_util::builder::command::CommandBuilder;
13+
use twilight_util::builder::InteractionResponseDataBuilder;
14+
15+
use crate::commands::CommandHandler;
16+
17+
const QUERY_OPTION_NAME: &str = "query";
18+
const MENTION_OPTION_NAME: &str = "mention";
19+
20+
#[allow(dead_code)]
21+
pub(crate) struct Faq<'a> {
22+
pub(crate) cmd: &'a Interaction,
23+
}
24+
25+
#[async_trait]
26+
impl CommandHandler for Faq<'_> {
27+
fn model(ctx: Option<crate::Context>) -> anyhow::Result<Command> {
28+
let ctx = ctx.expect("ctx is required");
29+
let query_option = CommandOptionBuilder::new(
30+
QUERY_OPTION_NAME,
31+
"The response to send.",
32+
CommandOptionType::String,
33+
)
34+
.choices(ctx.cfg.faq_option_choices())
35+
.required(true)
36+
.build()?;
37+
38+
let mention_option = CommandOptionBuilder::new(
39+
MENTION_OPTION_NAME,
40+
"The user to mention in the response.",
41+
CommandOptionType::User,
42+
)
43+
.build()?;
44+
45+
Ok(CommandBuilder::new(
46+
"faq",
47+
"Send quick responses to common questions/queries.",
48+
CommandType::ChatInput,
49+
)
50+
.contexts([InteractionContextType::Guild])
51+
.integration_types([ApplicationIntegrationType::GuildInstall])
52+
.default_member_permissions(Permissions::MANAGE_CHANNELS)
53+
.option(query_option)
54+
.option(mention_option)
55+
.validate()
56+
.context("validate faq command")?
57+
.build())
58+
}
59+
60+
async fn exec(&self, ctx: crate::Context) -> anyhow::Result<()> {
61+
let Some(InteractionData::ApplicationCommand(data)) = &self.cmd.data else {
62+
anyhow::bail!("expected application command interaction");
63+
};
64+
// Get the query option from the command data
65+
let query = data
66+
.options
67+
.iter()
68+
.find(|opt| opt.name == QUERY_OPTION_NAME)
69+
.context("missing query option")?;
70+
let CommandOptionValue::String(query) = &query.value else {
71+
anyhow::bail!("expected string query option");
72+
};
73+
let Some(embed) = ctx.cfg.faq_option_embed(query) else {
74+
anyhow::bail!("unknown query option: {}", query);
75+
};
76+
77+
// Create the response builder with an embed
78+
let mut response_builder = InteractionResponseDataBuilder::new().embeds([embed]);
79+
80+
// Add mention if provided
81+
let mention = data
82+
.options
83+
.iter()
84+
.find(|opt| opt.name == MENTION_OPTION_NAME);
85+
86+
if let Some(mention) = mention {
87+
let CommandOptionValue::User(u_id) = mention.value else {
88+
anyhow::bail!("expected user option");
89+
};
90+
response_builder = response_builder.content(format!("<@{u_id}>"));
91+
}
92+
93+
// Send the response
94+
ctx.http
95+
.interaction(self.cmd.application_id)
96+
.create_response(self.cmd.id, &self.cmd.token, &InteractionResponse {
97+
kind: InteractionResponseType::ChannelMessageWithSource,
98+
data: Some(response_builder.build()),
99+
})
100+
.await?;
101+
102+
Ok(())
103+
}
104+
}

bot/src/commands/mod.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ use async_trait::async_trait;
22
use twilight_model::application::command::Command;
33
use twilight_model::application::interaction::Interaction;
44

5-
pub(crate) mod devforum_self_role;
5+
mod devforum_self_role;
6+
mod faq;
67

78
/// Get all application command models.
8-
pub(crate) fn models() -> anyhow::Result<Vec<Command>> {
9-
Ok(vec![devforum_self_role::DevForumSelfRole::model()?])
9+
pub(crate) fn models(ctx: crate::Context) -> anyhow::Result<Vec<Command>> {
10+
Ok(vec![
11+
devforum_self_role::DevForumSelfRole::model(None)?,
12+
faq::Faq::model(Some(ctx))?,
13+
])
1014
}
1115

1216
/// Trait for implementing application commands.
1317
#[async_trait]
1418
pub(crate) trait CommandHandler: Send {
15-
fn model() -> anyhow::Result<Command>
19+
fn model(ctx: Option<crate::Context>) -> anyhow::Result<Command>
1620
where
1721
Self: Sized;
1822
async fn exec(&self, ctx: crate::Context) -> anyhow::Result<()>;
@@ -25,6 +29,7 @@ pub(crate) async fn handle_command(
2529
) -> anyhow::Result<()> {
2630
let handler: Box<dyn CommandHandler> = match cmd_name {
2731
"devforum-self-role" => Box::new(devforum_self_role::DevForumSelfRole { cmd }),
32+
"faq" => Box::new(faq::Faq { cmd }),
2833
unknown => anyhow::bail!("unknown command name: {}", unknown),
2934
};
3035
handler.exec(ctx).await

bot/src/config.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
use anyhow::Context;
22
use serde::Deserialize;
3+
use twilight_model::application::command::{CommandOptionChoice, CommandOptionChoiceValue};
4+
use twilight_model::channel::message::Embed;
35
use twilight_model::id::marker::RoleMarker;
46
use twilight_model::id::Id;
57

68
#[derive(Deserialize, Debug)]
79
pub(crate) struct Config {
810
pub(crate) roles: ConfigRoles,
11+
faq_options: Vec<FAQOption>,
912
}
1013

1114
#[derive(Deserialize, Debug)]
@@ -15,6 +18,36 @@ pub(crate) struct ConfigRoles {
1518
pub(crate) roblox_verified: Option<Id<RoleMarker>>,
1619
}
1720

21+
#[derive(Deserialize, Debug)]
22+
pub(crate) struct FAQOption {
23+
label: String,
24+
value: String,
25+
embed: Embed,
26+
}
27+
28+
impl Config {
29+
pub(crate) fn faq_option_choices(&self) -> Vec<CommandOptionChoice> {
30+
self.faq_options
31+
.iter()
32+
.map(|opt| CommandOptionChoice {
33+
name: opt.label.clone(),
34+
value: CommandOptionChoiceValue::String(opt.value.clone()),
35+
name_localizations: None,
36+
})
37+
.collect()
38+
}
39+
40+
pub(crate) fn faq_option_embed<S>(&self, value: S) -> Option<Embed>
41+
where
42+
S: AsRef<str>,
43+
{
44+
self.faq_options
45+
.iter()
46+
.find(|opt| opt.value == value.as_ref())
47+
.map(|opt| opt.embed.clone())
48+
}
49+
}
50+
1851
#[tracing::instrument(ret)]
1952
pub(crate) fn load_config<S>(path: S) -> Result<Config, anyhow::Error>
2053
where

bot/src/main.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ async fn handle_event_wrapper(
8686
Ok(())
8787
}
8888

89-
async fn handle_event(event: Event, state: Context) -> anyhow::Result<()> {
90-
match event {
89+
async fn handle_event(event: Event, ctx: Context) -> anyhow::Result<()> {
90+
let res: anyhow::Result<()> = match event {
9191
Event::Ready(client) => {
9292
tracing::info!(
9393
"the client has logged in as @{} ({})",
@@ -97,30 +97,31 @@ async fn handle_event(event: Event, state: Context) -> anyhow::Result<()> {
9797

9898
// Publish commands every time the bot starts
9999
// to ensure they are always up to date.
100-
let global_commands = state
100+
let global_commands = ctx
101101
.http
102102
.interaction(client.application.id)
103-
.set_global_commands(commands::models()?.as_slice())
103+
.set_global_commands(commands::models(ctx.clone())?.as_slice())
104104
.await
105105
.context("publish global commands")?
106106
.models()
107107
.await
108108
.context("get global commands")?;
109109

110110
tracing::info!("published {} global commands", global_commands.len());
111+
Ok(())
111112
},
112113
Event::InteractionCreate(interaction) => {
113114
match &interaction.data {
114115
Some(InteractionData::ApplicationCommand(command)) => {
115-
commands::handle_command(&interaction.0, command.name.as_str(), state.clone())
116+
commands::handle_command(&interaction.0, command.name.as_str(), ctx.clone())
116117
.await
117118
.with_context(|| format!("handle command: {}", command.name))?;
118119
},
119120
Some(InteractionData::MessageComponent(component)) => {
120121
components::handle_component(
121122
&interaction.0,
122123
component.custom_id.as_str(),
123-
state.clone(),
124+
ctx.clone(),
124125
)
125126
.await
126127
.with_context(|| format!("handle component: {}", component.custom_id))?;
@@ -133,8 +134,13 @@ async fn handle_event(event: Event, state: Context) -> anyhow::Result<()> {
133134
// },
134135
_ => anyhow::bail!("unsupported interaction type"),
135136
};
137+
Ok(())
136138
},
137-
_ => {},
139+
_ => Ok(()),
140+
};
141+
142+
if let Err(err) = res {
143+
tracing::error!(source = ?err, "error handling event");
138144
}
139145

140146
Ok(())

0 commit comments

Comments
 (0)