diff --git a/readme.md b/readme.md index 53a8605..90cda5e 100644 --- a/readme.md +++ b/readme.md @@ -10,11 +10,11 @@ The following features are currently supported: - Integration with [VRC Data Analysis](https://vrc-data-analysis.com/) to pull a VRC team's [TrueSkill](https://www.microsoft.com/en-us/research/project/trueskill-ranking-system/) value and rank. - The ability to look up articles on the [Purdue Sigbots wiki](wiki.purduesigbots.com/). - The ability to predict VRC match results, again using [VRC Data Analysis](https://vrc-data-analysis.com/). +- The ability to look up specific VRC game rules with their corresponding Q&As, powered by [qnaplus](https://qnapl.us) and [referee.fyi](https://referee.fyi). Additionally, the following features are in-development or planned: - The ability to view information about specific events. - The ability to perform lookups in documentation for other LemLib projects. -- The ability to look up specific VRC game rules (and potentially Q&A responses). ## Development diff --git a/src/api/mod.rs b/src/api/mod.rs index 1f1cb69..5322219 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,3 @@ pub mod vrc_data_analysis; +pub mod qnaplus; pub mod skills; \ No newline at end of file diff --git a/src/api/qnaplus/client.rs b/src/api/qnaplus/client.rs new file mode 100644 index 0000000..6c17ff2 --- /dev/null +++ b/src/api/qnaplus/client.rs @@ -0,0 +1,47 @@ +use reqwest::header::USER_AGENT; +use std::time::Duration; + +use crate::api::qnaplus::schema::*; + +#[derive(Default, Debug, Clone)] +pub struct Qnaplus { + pub req_client: reqwest::Client, +} + +pub const API_BASE: &str = "https://api.qnapl.us/api"; + +impl Qnaplus { + pub fn new() -> Self { + Self { + req_client: reqwest::Client::new(), + } + } + async fn request( + &self, + endpoint: impl AsRef, + ) -> Result { + Ok(self + .req_client + .get(format!("{API_BASE}{}", endpoint.as_ref())) + .header("accept-language", "en") + .header(USER_AGENT, "RoboStats Discord Bot") + .timeout(Duration::from_secs(10)) + .send() + .await?) + } + + pub async fn get_qnas_for_rule( + &self, + rule_name: &str, + season: Option<&str>, + ) -> Result { + let formatted_rule = rule_name.to_uppercase(); + let response = self + .request(format!( + "/rules/{formatted_rule}/qnas?season={}", + season.unwrap_or("") + )) + .await?; + Ok(response.json().await?) + } +} diff --git a/src/api/qnaplus/mod.rs b/src/api/qnaplus/mod.rs new file mode 100644 index 0000000..ec4d006 --- /dev/null +++ b/src/api/qnaplus/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod schema; + +pub use client::*; \ No newline at end of file diff --git a/src/api/qnaplus/schema.rs b/src/api/qnaplus/schema.rs new file mode 100644 index 0000000..97ad336 --- /dev/null +++ b/src/api/qnaplus/schema.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PartialQuestion { + pub id: String, + pub program: String, + pub season: String, + pub url: String, + pub title: String, + pub author: String, + pub answered: bool, + pub asked_timestamp_ms: i64, + pub answered_timestamp_ms: Option, + pub tags: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +pub struct Rule { + pub rule: String, + pub description: String, + pub link: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +pub struct RuleError { + pub message: String, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(untagged)] +pub enum RuleResponse { + RuleError(RuleError), + Rule(Rule), +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f935b6c..6390988 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod ping; pub mod team; pub mod wiki; pub mod predict; +pub mod rules; pub use ping::*; pub use team::*; diff --git a/src/commands/rules.rs b/src/commands/rules.rs new file mode 100644 index 0000000..9302ed1 --- /dev/null +++ b/src/commands/rules.rs @@ -0,0 +1,98 @@ +use serenity::all::CommandDataOptionValue; +use serenity::all::CommandInteraction; +use serenity::all::CommandOptionType; +use serenity::builder::CreateCommand; +use serenity::builder::CreateCommandOption; +use serenity::builder::CreateEmbed; +use serenity::builder::CreateEmbedFooter; +use serenity::builder::CreateInteractionResponseMessage; +use serenity::client::Context; + +use crate::api::qnaplus::schema::*; +use crate::api::qnaplus::Qnaplus; + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct RulesCommand; + +impl RulesCommand { + pub fn command() -> CreateCommand { + CreateCommand::new("rules") + .description("Search for rules and related Q&As.") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "The rule to retrieve, e.g., G4, SG11.", + ) + .required(true), + ) + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "season", + "The season to retrieve the rule from. Defaults to the current season.", + ) + .required(false), + ) + } + + pub async fn response( + &self, + _ctx: &Context, + interaction: &CommandInteraction, + qnaplus: &Qnaplus, + ) -> CreateInteractionResponseMessage { + let rule = if let CommandDataOptionValue::String(arg) = &interaction.data.options[0].value { + arg.trim() + } else { + return CreateInteractionResponseMessage::new().content("No argument provided"); + }; + + let season = match interaction.data.options.get(1) { + Some(option) => option.value.as_str(), + None => None, + }; + + let embed = match qnaplus.get_qnas_for_rule(rule, season).await { + Ok(response) => match response { + RuleResponse::RuleError(error) => { + CreateEmbed::new().title("API Error").description(format!( + "{}```txt\n{}```", + error.message, + error + .error + .unwrap_or("[no error stacktrace provided]".to_string()) + )) + } + RuleResponse::Rule(rule) => { + let Rule { + rule, + description, + link, + questions, + } = rule; + let questions = questions + .iter() + .map(|q| { + format!("{} - [{}]({})\n", &q.author, &q.title, &q.url).to_string() + }) + .collect::>() + .join(""); + + CreateEmbed::new() + .description(format!( + "## [{rule}]({link})\n{description}\n\n**Related Questions:**\n{questions}", + )) + .footer(CreateEmbedFooter::new( + "Data provided by qnapl.us and referee.fyi", + )) + } + }, + Err(err) => CreateEmbed::new() + .title("Failed to fetch data from qnaplus.") + .description(format!("```rs\n{err:?}```")), + }; + + CreateInteractionResponseMessage::new().add_embed(embed) + } +} diff --git a/src/main.rs b/src/main.rs index 9c7fe68..d4e62d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use commands::{ }; use api::{ vrc_data_analysis::VRCDataAnalysis, + qnaplus::Qnaplus, skills::SkillsCache, }; use robotevents::{ @@ -29,6 +30,8 @@ use robotevents::{ }; use shuttle_runtime::SecretStore; +use crate::commands::rules::RulesCommand; + mod api; mod commands; @@ -38,6 +41,7 @@ pub struct BotRequestError; struct Bot { robotevents: RobotEvents, vrc_data_analysis: VRCDataAnalysis, + qnaplus: Qnaplus, skills_cache: SkillsCache, season_list: Result, BotRequestError>, program_list: Result, BotRequestError> @@ -61,6 +65,7 @@ impl EventHandler for Bot { Command::create_global_command(&ctx.http, TeamCommand::command(self.program_list.clone().ok())).await.expect("Failed to register team command."); Command::create_global_command(&ctx.http, PingCommand::command()).await.expect("Failed to register ping command."); Command::create_global_command(&ctx.http, PredictCommand::command()).await.expect("Failed to register predict command."); + Command::create_global_command(&ctx.http, RulesCommand::command()).await.expect("Failed to register rules command."); } async fn interaction_create(&self, ctx: Context, interaction: Interaction) { @@ -72,6 +77,7 @@ impl EventHandler for Bot { let predict_command = PredictCommand::default(); let ping_command = PingCommand::default(); let wiki_command = WikiCommand::default(); + let rules_command = RulesCommand::default(); // Generate a response messaage for a given command type. let response_message = match command.data.name.as_str() { @@ -87,6 +93,9 @@ impl EventHandler for Bot { "wiki" => { wiki_command.response(&ctx, &command) }, + "rules" => { + rules_command.response(&ctx, &command, &self.qnaplus).await + }, _ => { CreateInteractionResponseMessage::new().content("not implemented :(") } @@ -160,6 +169,7 @@ async fn serenity( // HTTP clients for RobotEvents and vrc-data-analysis let robotevents = RobotEvents::new(robotevents_token); let vrc_data_analysis = VRCDataAnalysis::new(); + let qnaplus = Qnaplus::new(); // Build client with token and guild messages intent let client = Client::builder(discord_token, GatewayIntents::GUILD_MESSAGES) @@ -170,6 +180,7 @@ async fn serenity( season_list: robotevents.seasons(SeasonsQuery::default().per_page(250)).await.map_err(|_| BotRequestError), robotevents, vrc_data_analysis, + qnaplus, skills_cache: SkillsCache::default(), }) .await