use serenity::all::{
CommandInteraction, ComponentInteraction, Context, CreateInteractionResponseFollowup,
EventHandler, GuildId, Interaction, InteractionType, Ready,
};
use crate::{
bot::{
command::{self, Command, State},
embed::parse_custom_id,
},
error::Error,
limits,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Hook {
Run,
Autocomplete,
Button,
}
fn hook_for(kind: InteractionType) -> Option<Hook> {
match kind {
InteractionType::Command => Some(Hook::Run),
InteractionType::Autocomplete => Some(Hook::Autocomplete),
InteractionType::Component => Some(Hook::Button),
InteractionType::Ping | InteractionType::Modal | _ => None,
}
}
fn unknown_component_reply() -> &'static str {
"This looks like a button, but it is not a button this bot wrote. It may be left over from \
an older version of this dog."
}
trait Responder {
async fn tell(&self, message: &str) -> Result<(), Error>;
}
struct CommandResponder<'a> {
ctx: &'a Context,
interaction: &'a CommandInteraction,
}
impl Responder for CommandResponder<'_> {
async fn tell(&self, message: &str) -> Result<(), Error> {
self.interaction
.create_followup(
self.ctx,
CreateInteractionResponseFollowup::new()
.content(message)
.ephemeral(true),
)
.await?;
Ok(())
}
}
struct ComponentResponder<'a> {
ctx: &'a Context,
interaction: &'a ComponentInteraction,
}
impl Responder for ComponentResponder<'_> {
async fn tell(&self, message: &str) -> Result<(), Error> {
self.interaction
.create_followup(
self.ctx,
CreateInteractionResponseFollowup::new()
.content(message)
.ephemeral(true),
)
.await?;
Ok(())
}
}
fn registered_commands_message(names: &[String]) -> String {
format!(
"shep-discord: registered slash commands: {}",
names.join(", ")
)
}
fn could_not_register_message(err: &serenity::Error) -> String {
format!("shep-discord: could not register slash commands: {err}")
}
fn could_not_defer_command_message(name: &str, err: &serenity::Error) -> String {
format!("shep-discord: could not defer /{name}: {err}")
}
fn no_such_command_message(name: &str) -> String {
format!("shep-discord: no command named {name} is registered")
}
fn could_not_defer_button_message(err: &serenity::Error) -> String {
format!("shep-discord: could not defer a button: {err}")
}
fn command_failed_message(label: &str, err: &Error) -> String {
format!("shep-discord: /{label} failed: {err}")
}
fn could_not_tell_the_user_message(context: &str, err: &Error) -> String {
format!("shep-discord: {context}: could not tell the user: {err}")
}
async fn tell_or_log(context: &str, message: &str, responder: &impl Responder) -> Option<String> {
responder
.tell(message)
.await
.err()
.map(|err| could_not_tell_the_user_message(context, &err))
}
async fn report_failure(
label: &str,
outcome: Result<(), Error>,
responder: &impl Responder,
) -> Vec<String> {
let Err(err) = outcome else {
return Vec::new();
};
let mut lines = vec![command_failed_message(label, &err)];
let content = limits::fit(
&format!("Something went wrong: {err}"),
limits::MESSAGE_CONTENT_LIMIT,
);
if let Some(line) = tell_or_log(&format!("/{label} failed"), &content, responder).await {
lines.push(line);
}
lines
}
pub struct Handler {
state: State,
commands: Vec<Box<dyn Command>>,
guild_id: GuildId,
}
impl Handler {
#[must_use]
pub fn new(state: State, guild_id: GuildId) -> Self {
Self {
state,
commands: command::registry(),
guild_id,
}
}
fn find(&self, name: &str) -> Option<&dyn Command> {
self.commands
.iter()
.find(|command| command.name() == name)
.map(Box::as_ref)
}
}
#[serenity::async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, _ready: Ready) {
match command::register(&ctx.http, self.guild_id, &self.commands).await {
Ok(names) => println!("{}", registered_commands_message(&names)),
Err(err) => eprintln!("{}", could_not_register_message(&err)),
}
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match (hook_for(interaction.kind()), interaction) {
(Some(Hook::Run), Interaction::Command(command_interaction)) => {
self.handle_command(&ctx, &command_interaction).await;
}
(Some(Hook::Autocomplete), Interaction::Autocomplete(command_interaction)) => {
if let Some(command) = self.find(&command_interaction.data.name) {
command
.autocomplete(&ctx, &command_interaction, &self.state)
.await;
}
}
(Some(Hook::Button), Interaction::Component(component_interaction)) => {
self.handle_component(&ctx, &component_interaction).await;
}
_ => {}
}
}
}
impl Handler {
async fn handle_command(&self, ctx: &Context, interaction: &CommandInteraction) {
let name = interaction.data.name.clone();
if let Err(err) = interaction.defer_ephemeral(ctx).await {
eprintln!("{}", could_not_defer_command_message(&name, &err));
return;
}
let Some(command) = self.find(&name) else {
eprintln!("{}", no_such_command_message(&name));
return;
};
let outcome = command.run(ctx, interaction, &self.state).await;
let responder = CommandResponder { ctx, interaction };
for line in report_failure(&name, outcome, &responder).await {
eprintln!("{line}");
}
}
async fn handle_component(&self, ctx: &Context, interaction: &ComponentInteraction) {
if let Err(err) = interaction.defer_ephemeral(ctx).await {
eprintln!("{}", could_not_defer_button_message(&err));
return;
}
let responder = ComponentResponder { ctx, interaction };
if parse_custom_id(&interaction.data.custom_id).is_none() {
if let Some(line) =
tell_or_log("an unknown button", unknown_component_reply(), &responder).await
{
eprintln!("{line}");
}
return;
}
let Some(command) = self
.commands
.iter()
.find(|command| command.handles_button(&interaction.data.custom_id))
else {
if let Some(line) =
tell_or_log("an unclaimed button", unknown_component_reply(), &responder).await
{
eprintln!("{line}");
}
return;
};
let outcome = command.button(ctx, interaction, &self.state).await;
for line in report_failure(command.name(), outcome, &responder).await {
eprintln!("{line}");
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
#[test]
fn each_interaction_kind_reaches_its_own_hook() {
assert_eq!(hook_for(InteractionType::Command), Some(Hook::Run));
assert_eq!(
hook_for(InteractionType::Autocomplete),
Some(Hook::Autocomplete)
);
assert_eq!(hook_for(InteractionType::Component), Some(Hook::Button));
assert_eq!(hook_for(InteractionType::Ping), None);
assert_eq!(hook_for(InteractionType::Modal), None);
}
struct FailingResponder;
impl Responder for FailingResponder {
async fn tell(&self, _message: &str) -> Result<(), Error> {
Err(Error::Config("the followup itself failed".to_owned()))
}
}
#[derive(Default)]
struct RecordingResponder {
seen: Mutex<Option<String>>,
}
impl RecordingResponder {
fn seen(&self) -> Option<String> {
self.seen.lock().expect("lock").clone()
}
}
impl Responder for RecordingResponder {
async fn tell(&self, message: &str) -> Result<(), Error> {
*self.seen.lock().expect("lock") = Some(message.to_owned());
Ok(())
}
}
#[tokio::test]
async fn a_failure_to_report_a_failure_still_reaches_stderr() {
let command_err = Error::Config("the command itself failed".to_owned());
let followup_err = Error::Config("the followup itself failed".to_owned());
let lines = report_failure("system", Err(command_err), &FailingResponder).await;
assert_eq!(
lines,
vec![
command_failed_message(
"system",
&Error::Config("the command itself failed".to_owned())
),
could_not_tell_the_user_message("/system failed", &followup_err),
]
);
}
#[tokio::test]
async fn a_success_reports_nothing() {
let lines = report_failure("system", Ok(()), &FailingResponder).await;
assert!(lines.is_empty(), "{lines:?}");
}
#[tokio::test]
async fn a_followup_message_fits_discords_own_content_limit() {
let huge = "x".repeat(3000);
let responder = RecordingResponder::default();
let _ = report_failure("system", Err(Error::Config(huge)), &responder).await;
let seen = responder.seen().expect("report_failure told the user");
assert!(seen.chars().count() <= limits::MESSAGE_CONTENT_LIMIT);
}
#[tokio::test]
async fn an_unknown_component_id_is_answered_rather_than_ignored() {
assert!(parse_custom_id("something:else").is_none());
let responder = RecordingResponder::default();
let line = tell_or_log("an unknown button", unknown_component_reply(), &responder).await;
assert_eq!(line, None);
assert_eq!(responder.seen(), Some(unknown_component_reply().to_owned()));
}
#[tokio::test]
async fn telling_an_unknown_button_user_logs_when_that_also_fails() {
let followup_err = Error::Config("the followup itself failed".to_owned());
let line = tell_or_log(
"an unknown button",
unknown_component_reply(),
&FailingResponder,
)
.await
.expect("a failing responder is reported");
assert_eq!(
line,
could_not_tell_the_user_message("an unknown button", &followup_err)
);
}
#[tokio::test]
async fn nothing_printed_for_a_person_carries_a_dash() {
use crate::test_support::assert_no_dashes;
assert_no_dashes(unknown_component_reply());
let err = Error::Config("a value in dogs.toml".to_owned());
let responder = RecordingResponder::default();
let lines = report_failure("system", Err(err), &responder).await;
for line in &lines {
assert_no_dashes(line);
}
assert_no_dashes(&responder.seen().expect("report_failure told the user"));
assert_no_dashes(&command_failed_message(
"system",
&Error::Config("a value in dogs.toml".to_owned()),
));
assert_no_dashes(&could_not_tell_the_user_message(
"an unknown button",
&Error::Config("the followup itself failed".to_owned()),
));
let refused = serenity::Error::Other("refused");
assert_no_dashes(®istered_commands_message(&[
"system".to_owned(),
"shep".to_owned(),
]));
assert_no_dashes(&could_not_register_message(&refused));
assert_no_dashes(&could_not_defer_command_message("shep", &refused));
assert_no_dashes(&no_such_command_message("shep"));
assert_no_dashes(&could_not_defer_button_message(&refused));
}
}