diff options
author | Nathan Ringo <nathan@remexre.com> | 2024-01-18 10:58:36 -0600 |
---|---|---|
committer | Nathan Ringo <nathan@remexre.com> | 2024-01-18 10:58:36 -0600 |
commit | 00d0bfced902e97eeae5257c14134d4bc7efc710 (patch) | |
tree | ee026f328614e03aec3ed373d9f2e6c8e255f834 /src/commands | |
parent | 7017762a4a38266aa88976be141f7bd663647edc (diff) |
Commands to interact with discocaml, associated IPC.
Diffstat (limited to 'src/commands')
-rw-r--r-- | src/commands/discocaml.rs | 177 | ||||
-rw-r--r-- | src/commands/mod.rs | 47 |
2 files changed, 224 insertions, 0 deletions
diff --git a/src/commands/discocaml.rs b/src/commands/discocaml.rs new file mode 100644 index 0000000..93ddfaa --- /dev/null +++ b/src/commands/discocaml.rs @@ -0,0 +1,177 @@ +use anyhow::{anyhow, bail, Context as _, Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::de::Deserializer; +use serenity::{ + all::{ + CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Member, RoleId, + }, + builder::{ + CreateCommand, CreateCommandOption, CreateInteractionResponse, + CreateInteractionResponseMessage, + }, + client::Context, + model::Permissions, +}; +use sqlx::SqlitePool; +use std::process::Stdio; +use tokio::{io::AsyncWriteExt, process::Command}; + +use crate::utils::EnumAsArray; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscocamlConfig { + pub command: Vec<String>, + pub role: RoleId, +} + +#[derive(Debug, Serialize)] +struct DiscocamlRequest { + expr: String, + command: DiscocamlCommand, +} + +#[derive(Debug, Serialize)] +enum DiscocamlCommand { + Roundtrip, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +enum DiscocamlResponse { + Error(String), +} + +pub fn command() -> CreateCommand { + CreateCommand::new("discocaml") + .kind(CommandType::ChatInput) + .default_member_permissions(Permissions::empty()) + .dm_permission(true) + .description("Sends this expression to the disco!") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "expr", + "The expression to operate on.", + ) + .required(true), + ) +} + +async fn check_permissions(config: &DiscocamlConfig, member: &Option<Box<Member>>) -> Result<()> { + if let Some(member) = member { + if !member.roles.contains(&config.role) { + bail!("This command can only be used by <@&{}>.", config.role) + } + Ok(()) + } else { + bail!("This command cannot be used in DMs.") + } +} + +async fn respond_with_error(ctx: &Context, command: &CommandInteraction, err: &Error) { + let msg = CreateInteractionResponseMessage::new().content(format!(":no_entry_sign: {}", err)); + if let Err(err) = command + .create_response(ctx, CreateInteractionResponse::Message(msg)) + .await + { + log::error!( + "failed to respond to command that failed permissions check: {:?}", + err + ) + } +} + +async fn run_discocaml( + config: &DiscocamlConfig, + req: &DiscocamlRequest, +) -> Result<DiscocamlResponse> { + let mut child = Command::new(&config.command[0]) + .args(&config.command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("failed to start discocaml")?; + + let mut stdin = child.stdin.take().unwrap(); + let req = serde_json::to_string(req).context("failed to serialize request to discocaml")?; + stdin + .write_all(req.as_bytes()) + .await + .context("failed to write request to discocaml")?; + stdin + .shutdown() + .await + .context("failed to close pipe to discocaml")?; + drop(stdin); + drop(req); + + let output = child + .wait_with_output() + .await + .context("failed to wait for discocaml to complete")?; + if !output.status.success() { + bail!("discocaml exited with non-zero status {:?}", output.status) + } + + let mut de = Deserializer::from_slice(&output.stdout); + let out = DiscocamlResponse::deserialize(EnumAsArray(&mut de)) + .context("failed to parse response from discocaml")?; + de.end() + .context("failed to parse response from discocaml")?; + Ok(out) +} + +pub async fn handle_command( + ctx: &Context, + config: &DiscocamlConfig, + _db: &SqlitePool, + command: &CommandInteraction, +) -> Result<()> { + // Check that the required role was present. + if let Err(err) = check_permissions(config, &command.member).await { + respond_with_error(ctx, command, &err).await; + return Err(err.context("permissions check failed")); + } + + // Parse the expression out. + let mut expr = None; + for option in &command.data.options { + match (&option.name as &str, &option.value) { + ("expr", CommandDataOptionValue::String(value)) => expr = Some(value), + _ => { + let err = anyhow!("unknown option {:?}", option); + respond_with_error(ctx, command, &err).await; + return Err(err); + } + } + } + let expr = if let Some(expr) = expr { + expr + } else { + let err = anyhow!("missing option {:?}", "expr"); + respond_with_error(ctx, command, &err).await; + return Err(err); + }; + + // Round-trip the expression through discocaml. + let req = DiscocamlRequest { + expr: expr.to_string(), + command: DiscocamlCommand::Roundtrip, + }; + let res = match run_discocaml(config, &req).await { + Ok(res) => res, + Err(err) => { + let err = err.context("failed to run discocaml"); + respond_with_error(ctx, command, &err).await; + return Err(err); + } + }; + + let msg = CreateInteractionResponseMessage::new().content(format!("`{:?}`", res)); + command + .create_response(&ctx, CreateInteractionResponse::Message(msg)) + .await + .context("failed to respond") +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..083b200 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,47 @@ +mod discocaml; + +use crate::commands::discocaml::DiscocamlConfig; +use anyhow::{Context as _, Result}; +use serde::Deserialize; +use serenity::{ + all::{Command, Interaction}, + client::Context, +}; +use sqlx::SqlitePool; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommandsConfig { + pub discocaml: DiscocamlConfig, +} + +pub async fn set_commands(ctx: &Context) -> Result<()> { + Command::set_global_commands(&ctx.http, vec![discocaml::command()]) + .await + .context("failed to set commands")?; + Ok(()) +} + +pub async fn handle_interaction( + ctx: &Context, + config: &CommandsConfig, + db: &SqlitePool, + interaction: &Interaction, +) -> Result<()> { + match interaction { + Interaction::Command(cmd) => match &cmd.data.name as &str { + "discocaml" => discocaml::handle_command(ctx, &config.discocaml, db, cmd) + .await + .context("failed to handle discocaml command"), + _ => { + log::warn!("unexpected interaction: {:?}", interaction); + Ok(()) + } + }, + + _ => { + log::warn!("unexpected interaction: {:?}", interaction); + Ok(()) + } + } +} |