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::{ CreateButton, 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, pub role: RoleId, } #[derive(Debug, PartialEq, Serialize)] pub struct DiscocamlRequest { pub expr: String, pub command: DiscocamlCommand, } #[derive(Debug, PartialEq, Serialize)] pub enum DiscocamlCommand { Roundtrip, } /// A response outputted by the discocaml subprocess as a JSON string. /// /// ``` /// # use lambo::{commands::discocaml::*, utils::EnumAsArray}; /// # use serde::Deserialize; /// # use serde_json::Deserializer; /// /// let example = r#" /// [ "Expr" /// , { "expr": "1 + 2" /// } /// ] /// "#; /// let expected = DiscocamlResponse::Expr(DiscocamlResponseExpr { /// expr: "1 + 2".to_string(), /// }); /// /// let mut de = Deserializer::from_str(&example); /// let out = DiscocamlResponse::deserialize(EnumAsArray(&mut de)).unwrap(); /// de.end().unwrap(); /// /// assert_eq!(out, expected); /// ``` #[derive(Debug, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub enum DiscocamlResponse { Expr(DiscocamlResponseExpr), Error(String), } #[derive(Debug, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct DiscocamlResponseExpr { pub expr: 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>) -> 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 ) } } pub async fn run_discocaml( config: &DiscocamlConfig, req: &DiscocamlRequest, ) -> Result { 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")?; match out { DiscocamlResponse::Expr(expr) => Ok(expr), DiscocamlResponse::Error(err) => bail!("got an error from discocaml: {:?}", err), } } fn make_response_message(expr: &DiscocamlResponseExpr) -> CreateInteractionResponseMessage { // TODO: Real escaping CreateInteractionResponseMessage::new() .content(format!("```ocaml\n{}\n```", expr.expr)) .button(CreateButton::new("step-cbv").label("Step (CBV)")) .button(CreateButton::new("step-cbn").label("Step (CBN)")) .button(CreateButton::new("run-cbv").label("Run (CBV)")) .button(CreateButton::new("run-cbn").label("Run (CBN)")) .button(CreateButton::new("draw-tree").label("Draw Tree")) } 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) => { respond_with_error(ctx, command, &err).await; return Err(err.context("failed to run discocaml")); } }; // Respond with the expression and the buttons. command .create_response( &ctx, CreateInteractionResponse::Message(make_response_message(&res)), ) .await .context("failed to respond") }