aboutsummaryrefslogtreecommitdiff
path: root/src/commands
diff options
context:
space:
mode:
authorNathan Ringo <nathan@remexre.com>2024-01-18 10:58:36 -0600
committerNathan Ringo <nathan@remexre.com>2024-01-18 10:58:36 -0600
commit00d0bfced902e97eeae5257c14134d4bc7efc710 (patch)
treeee026f328614e03aec3ed373d9f2e6c8e255f834 /src/commands
parent7017762a4a38266aa88976be141f7bd663647edc (diff)
Commands to interact with discocaml, associated IPC.
Diffstat (limited to 'src/commands')
-rw-r--r--src/commands/discocaml.rs177
-rw-r--r--src/commands/mod.rs47
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(())
+ }
+ }
+}