Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod vrc_data_analysis;
pub mod qnaplus;
pub mod skills;
47 changes: 47 additions & 0 deletions src/api/qnaplus/client.rs
Original file line number Diff line number Diff line change
@@ -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<str>,
) -> Result<reqwest::Response, reqwest::Error> {
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<RuleResponse, reqwest::Error> {
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?)
}
}
4 changes: 4 additions & 0 deletions src/api/qnaplus/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod client;
pub mod schema;

pub use client::*;
37 changes: 37 additions & 0 deletions src/api/qnaplus/schema.rs
Original file line number Diff line number Diff line change
@@ -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<i64>,
pub tags: Vec<String>,
}

#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct Rule {
pub rule: String,
pub description: String,
pub link: String,
pub questions: Vec<PartialQuestion>,
}

#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct RuleError {
pub message: String,
pub error: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum RuleResponse {
RuleError(RuleError),
Rule(Rule),
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
98 changes: 98 additions & 0 deletions src/commands/rules.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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)
}
}
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use commands::{
};
use api::{
vrc_data_analysis::VRCDataAnalysis,
qnaplus::Qnaplus,
skills::SkillsCache,
};
use robotevents::{
Expand All @@ -29,6 +30,8 @@ use robotevents::{
};
use shuttle_runtime::SecretStore;

use crate::commands::rules::RulesCommand;

mod api;
mod commands;

Expand All @@ -38,6 +41,7 @@ pub struct BotRequestError;
struct Bot {
robotevents: RobotEvents,
vrc_data_analysis: VRCDataAnalysis,
qnaplus: Qnaplus,
skills_cache: SkillsCache,
season_list: Result<PaginatedResponse<Season>, BotRequestError>,
program_list: Result<PaginatedResponse<IdInfo>, BotRequestError>
Expand All @@ -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) {
Expand All @@ -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() {
Expand All @@ -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 :(")
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down