use anyhow::{anyhow, bail, Context as _, Error, Result}; use futures::Future; use serde::{Deserialize, Serialize}; use serde_json::de::Deserializer; use serenity::{ all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, ComponentInteraction, ComponentInteractionDataKind, InteractionId, InteractionResponseFlags, 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 { Parse, DrawTree, RunCBN, RunCBV, StepCBN, StepCBV, } /// 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" /// , "has_redex": true /// } /// ] /// "#; /// let expected = DiscocamlResponse::Expr(DiscocamlResponseExpr { /// expr: "1 + 2".to_string(), /// has_redex: true, /// }); /// /// 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 has_redex: bool, } impl DiscocamlResponseExpr { async fn save(&self, db: &SqlitePool, id: InteractionId) -> Result<()> { let interaction_id = i64::from(id); sqlx::query!( "INSERT INTO discocaml_exprs (interaction_id, expr) VALUES (?, ?)", interaction_id, self.expr ) .execute(db) .await?; Ok(()) } } 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<&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>>( err: &Error, send: impl FnOnce(CreateInteractionResponse) -> F, ) { let msg = CreateInteractionResponseMessage::new() .content(format!(":no_entry_sign: {}", err)) .flags(InteractionResponseFlags::EPHEMERAL); if let Err(err) = send(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("draw-tree").label("Draw Tree")) .button( CreateButton::new("step-cbv") .label("Step (CBV)") .disabled(!expr.has_redex), ) .button( CreateButton::new("step-cbn") .label("Step (CBN)") .disabled(!expr.has_redex), ) .button( CreateButton::new("run-cbv") .label("Run (CBV)") .disabled(!expr.has_redex), ) .button( CreateButton::new("run-cbn") .label("Run (CBN)") .disabled(!expr.has_redex), ) } 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.as_deref()).await { respond_with_error(&err, |res| command.create_response(&ctx, res)).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(&err, |res| command.create_response(&ctx, res)).await; return Err(err); } } } let expr = if let Some(expr) = expr { expr } else { let err = anyhow!("missing option {:?}", "expr"); respond_with_error(&err, |res| command.create_response(&ctx, res)).await; return Err(err); }; // Round-trip the expression through discocaml. let req = DiscocamlRequest { expr: expr.to_string(), command: DiscocamlCommand::Parse, }; let res = match run_discocaml(config, &req).await { Ok(res) => res, Err(err) => { respond_with_error(&err, |res| command.create_response(&ctx, res)).await; return Err(err.context("failed to run discocaml")); } }; // Insert the output expression in the database. if let Err(err) = res .save(db, command.id) .await .context("failed to save expression to database") { respond_with_error(&err, |res| command.create_response(&ctx, res)).await; return Err(err); } // Respond with the expression and the buttons. command .create_response( &ctx, CreateInteractionResponse::Message(make_response_message(&res)), ) .await .context("failed to respond") } pub async fn handle_button( ctx: &Context, config: &DiscocamlConfig, db: &SqlitePool, component: &ComponentInteraction, ) -> Result<()> { // Check that the required role was present. if let Err(err) = check_permissions(config, component.member.as_ref()).await { respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err.context("permissions check failed")); } // Find the interaction ID from the message it was in response to. let interaction_id = if let Some(interaction) = &component.message.interaction { i64::from(interaction.id) } else { let err = anyhow!( "button was pressed in response to an unknown message {:?}", component.message.id ); respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err); }; // Fetch the expression it was in response to. let result = sqlx::query!( "SELECT expr FROM discocaml_exprs WHERE interaction_id = ?", interaction_id, ) .fetch_one(db) .await .context("failed to load expression from database"); let expr = match result { Ok(expr) => expr.expr, Err(err) => { respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err); } }; // Come up with a command for discocaml based on which button it was. let command = match (&component.data.kind, &component.data.custom_id as &str) { (ComponentInteractionDataKind::Button, "draw-tree") => DiscocamlCommand::DrawTree, (ComponentInteractionDataKind::Button, "step-cbv") => DiscocamlCommand::StepCBV, (ComponentInteractionDataKind::Button, "step-cbn") => DiscocamlCommand::StepCBN, (ComponentInteractionDataKind::Button, "run-cbv") => DiscocamlCommand::RunCBV, (ComponentInteractionDataKind::Button, "run-cbn") => DiscocamlCommand::RunCBN, _ => { let err = anyhow!("unknown component {:?}", component.data); respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err); } }; // Send discocaml the request. let req = DiscocamlRequest { expr, command }; let res = match run_discocaml(config, &req).await { Ok(res) => res, Err(err) => { respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err.context("failed to run discocaml")); } }; // Insert the output expression in the database. if let Err(err) = res .save(db, component.id) .await .context("failed to save expression to database") { respond_with_error(&err, |res| component.create_response(&ctx, res)).await; return Err(err); } // Respond with the expression and the buttons. component .create_response( &ctx, CreateInteractionResponse::Message(make_response_message(&res)), ) .await .context("failed to respond") }