aboutsummaryrefslogtreecommitdiff
path: root/src/commands/game.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/commands/game.rs')
-rw-r--r--src/commands/game.rs572
1 files changed, 572 insertions, 0 deletions
diff --git a/src/commands/game.rs b/src/commands/game.rs
new file mode 100644
index 0000000..72633b5
--- /dev/null
+++ b/src/commands/game.rs
@@ -0,0 +1,572 @@
+use std::{
+ fs,
+ iter,
+ path::PathBuf,
+ result::Result as StdResult,
+ str::{
+ self,
+ FromStr,
+ },
+};
+
+use anyhow::anyhow;
+use fnv::{
+ FnvHashMap,
+ FnvHashSet,
+};
+use itertools::Itertools;
+use lazy_static::lazy_static;
+use log::{
+ debug,
+ error,
+ info,
+};
+use serde::Deserialize;
+use serenity::model::{
+ guild::Guild,
+ id::UserId,
+};
+use tap::Pipe;
+use url::Url;
+
+use crate::{
+ bot::HttpKey,
+ util,
+ PoiseContext,
+ Result,
+ CONFIG,
+};
+
+lazy_static! {
+ static ref SPREADSHEET_URL: Url = Url::parse(&format!(
+ "https://sheets.googleapis.com/v4/spreadsheets/{}/values:batchGet",
+ &CONFIG.sheets.spreadsheet,
+ ))
+ .expect("parsing spreadsheet url");
+}
+
+#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
+struct UserInfo {
+ name: String,
+
+ #[serde(flatten)]
+ profile: ProfileInfo,
+}
+
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct ProfileInfo {
+ #[serde(rename = "steam")]
+ steam_id: Option<u64>,
+
+ #[serde(rename = "discord")]
+ discord_user_id: u64,
+}
+
+lazy_static! {
+ static ref USER_MAP_STR: String = {
+ let default_path = PathBuf::from_str("user_id_mapping").unwrap();
+ let mapping_path = CONFIG.user_id_mapping.as_ref().unwrap_or(&default_path);
+
+ fs::read_to_string(mapping_path).unwrap_or("{}".to_owned())
+ };
+ static ref USER_INFO_MAP: FnvHashMap<String, ProfileInfo> = {
+ let v: Vec<UserInfo> = serde_json::from_str(&USER_MAP_STR).unwrap();
+
+ let result = v
+ .into_iter()
+ .map(|ui| {
+ let UserInfo {
+ name,
+ profile,
+ } = ui;
+
+ (name, profile)
+ })
+ .collect::<FnvHashMap<_, _>>();
+
+ info!(
+ "loaded user info for {} users ({:#?})",
+ result.len(),
+ result.keys().collect::<Vec<_>>()
+ );
+
+ result
+ };
+ static ref DISCORD_MAP: FnvHashMap<UserId, String> = {
+ USER_INFO_MAP
+ .clone()
+ .into_iter()
+ .map(|(name, profile)| (UserId::new(profile.discord_user_id), name))
+ .collect::<FnvHashMap<_, _>>()
+ };
+ static ref STEAM_MAP: FnvHashMap<UserId, u64> = {
+ USER_INFO_MAP
+ .clone()
+ .into_iter()
+ .filter_map(|(_, profile)| {
+ profile.steam_id.map(|sid| (UserId::new(profile.discord_user_id), sid))
+ })
+ .collect::<FnvHashMap<_, _>>()
+ };
+ static ref ALPHABET: Vec<char> = (0..26).map(|x| (x + b'a') as char).collect();
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd)]
+enum GameStatus {
+ Installed,
+ NotInstalled,
+ NotOwned,
+ Unknown,
+}
+
+impl FromStr for GameStatus {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self> {
+ use std::char;
+
+ if s.starts_with('y') {
+ Ok(GameStatus::Installed)
+ } else if s.starts_with("n/i") {
+ Ok(GameStatus::NotInstalled)
+ } else if s.starts_with('n') {
+ Ok(GameStatus::NotOwned)
+ } else if s.chars().all(char::is_whitespace) {
+ Ok(GameStatus::Unknown)
+ } else {
+ Err(anyhow!(format!("unexpected status '{}'", s)))
+ }
+ }
+}
+
+pub fn commands() -> Vec<poise::Command<crate::PoiseData, anyhow::Error>> {
+ vec![installedgame(), ownedgame(), game(), updategaem()]
+}
+
+#[poise::command(prefix_command, guild_only, category = "gaem", aliases("installedgaem"))]
+pub async fn installedgame(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> {
+ _game(ctx, args.into_inner(), GameStatus::Installed).await
+}
+
+#[poise::command(prefix_command, guild_only, category = "gaem", aliases("ownedgaem"))]
+pub async fn ownedgame(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> {
+ _game(ctx, args.into_inner(), GameStatus::NotInstalled).await
+}
+
+#[derive(Copy, Clone, Debug, thiserror::Error, PartialEq, Eq, Hash)]
+pub enum UserLookupError {
+ #[error("too many possible options ({}) for query", _0)]
+ Ambiguous(usize),
+
+ #[error("user wasn't found in the guild")]
+ NotFound,
+}
+
+pub fn get_user_id<S: AsRef<str>>(g: &Guild, s: S) -> StdResult<UserId, UserLookupError> {
+ let s = s.as_ref().trim_start_matches('@').to_lowercase();
+
+ if let Some(info) = USER_INFO_MAP.get(&s) {
+ return Ok(UserId::new(info.discord_user_id));
+ }
+
+ let nicks = g.members_nick_containing(&s, false, false);
+
+ {
+ let exact_match = nicks.iter().find(|(m, _)| m.display_name().to_lowercase() == s);
+
+ if let Some((m, _)) = exact_match {
+ return Ok(m.user.id);
+ }
+ }
+
+ let usernames = g.members_username_containing(&s, false, false);
+
+ {
+ let exact_match = usernames.iter().find(|(m, _)| m.user.name.to_lowercase() == s);
+
+ if let Some((m, _)) = exact_match {
+ return Ok(m.user.id);
+ }
+ }
+
+ let opts = nicks
+ .into_iter()
+ .chain(usernames)
+ .map(|(member, _)| member.user.id)
+ .collect::<FnvHashSet<_>>();
+
+ match opts.len() {
+ 0 => Err(UserLookupError::NotFound),
+ 1 => Ok(opts.into_iter().next().unwrap()),
+ x => Err(UserLookupError::Ambiguous(x)),
+ }
+}
+
+#[poise::command(prefix_command, guild_only, category = "gaem", aliases("gaem"))]
+async fn game(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> {
+ _game(ctx, args.into_inner(), GameStatus::Installed).await
+}
+
+async fn _game(
+ ctx: PoiseContext<'_>,
+ user_args: Vec<String>,
+ min_status: GameStatus,
+) -> anyhow::Result<()> {
+ use serenity::futures::StreamExt;
+
+ let users = {
+ let guild = ctx
+ .channel_id()
+ .to_channel(&ctx)
+ .await?
+ .guild()
+ .ok_or(anyhow!("couldn't find guild"))?;
+
+ let mut users = user_args
+ .into_iter()
+ .pipe(serenity::futures::stream::iter)
+ .filter_map(|u| {
+ let guild = &guild;
+ async move {
+ use std::borrow::Borrow;
+
+ let possible = {
+ let Ok(guild) =
+ guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild"))
+ else {
+ error!("failed retrieving guild");
+ return None;
+ };
+
+ get_user_id(guild.borrow(), &u)
+ };
+
+ debug!("parsed userid {:?}", possible);
+
+ match possible {
+ Ok(x) => Some(x),
+ Err(UserLookupError::NotFound) => {
+ let _ = util::reply(ctx, format!("didn't recognize {u}")).await;
+
+ None
+ },
+ Err(UserLookupError::Ambiguous(x)) => {
+ let _ =
+ util::reply(ctx, format!("too many matches ({x}) for {u}")).await;
+ None
+ },
+ }
+ }
+ })
+ .filter_map(|uid| async move {
+ let res = DISCORD_MAP.get(&uid).map(|s| s.to_lowercase());
+
+ if res.is_none() {
+ info!("user {uid} is not recognized");
+ }
+
+ res
+ })
+ .collect::<FnvHashSet<_>>()
+ .await;
+
+ if users.is_empty() {
+ let guild = guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild"))?;
+
+ let pairs = guild
+ .voice_states
+ .iter()
+ .filter_map(|(uid, voice)| voice.channel_id.map(|cid| (*uid, cid)))
+ .collect::<FnvHashMap<_, _>>();
+
+ let channel =
+ pairs.get(&ctx.author().id).cloned().unwrap_or(CONFIG.discord.voice_channel());
+
+ users = pairs
+ .iter()
+ .filter_map(|(uid, cid)| {
+ if *cid == channel {
+ DISCORD_MAP.get(uid).map(|s| s.to_lowercase())
+ } else {
+ None
+ }
+ })
+ .collect::<FnvHashSet<_>>();
+ }
+
+ users
+ };
+
+ let inferred = users.is_empty();
+
+ if inferred && users.len() < 2 || !inferred && users.is_empty() {
+ info!("too few known users to make game comparison");
+ util::reply(ctx, "yer too lonely").await?;
+ return Ok(());
+ }
+
+ let client = {
+ let data = ctx.serenity_context().data.read().await;
+ data.get::<HttpKey>().unwrap().clone()
+ };
+ let data = load_spreadsheet(&client).await?;
+
+ let user_indexes = (0..data.len())
+ .filter_map(|i| {
+ let user = data[i][0].to_lowercase();
+
+ if users.contains(&user) {
+ Some((user, i))
+ } else {
+ None
+ }
+ })
+ .collect::<FnvHashMap<_, _>>();
+
+ let data_ref = &data;
+ let user_games = user_indexes
+ .iter()
+ .map(|(user, col)| {
+ let empty_hash_set: FnvHashSet<_> = vec![].into_iter().collect();
+
+ let mut game_map = vec![
+ (GameStatus::Installed, empty_hash_set.clone()),
+ (GameStatus::NotInstalled, empty_hash_set.clone()),
+ (GameStatus::NotOwned, empty_hash_set.clone()),
+ (GameStatus::Unknown, empty_hash_set),
+ ]
+ .into_iter()
+ .collect::<FnvHashMap<_, _>>();
+
+ (1..data[*col].len()).for_each(|i| {
+ let status =
+ &data_ref[*col][i].parse::<GameStatus>().unwrap_or(GameStatus::Unknown);
+ let game = &data_ref[0][i];
+
+ game_map.get_mut(status).unwrap().insert(game);
+ });
+
+ (user, game_map)
+ })
+ .collect::<FnvHashMap<_, _>>();
+
+ let statuses = vec![
+ GameStatus::Installed,
+ GameStatus::NotOwned,
+ GameStatus::NotInstalled,
+ GameStatus::Unknown,
+ ]
+ .into_iter()
+ .filter(|s| s <= &min_status)
+ .collect::<Vec<_>>();
+
+ let mut games_in_common = {
+ let game_map = user_games.values().next().unwrap();
+
+ statuses.iter().fold(iter::empty().collect::<FnvHashSet<_>>(), |acc, s| {
+ acc.union(&game_map[s]).cloned().collect()
+ })
+ };
+
+ for (_user, game_map) in user_games.iter() {
+ let relevant_games =
+ statuses.iter().fold(iter::empty().collect::<FnvHashSet<_>>(), |acc, s| {
+ acc.union(&game_map[s]).cloned().collect()
+ });
+
+ games_in_common = games_in_common.intersection(&relevant_games).cloned().collect();
+ }
+
+ let mut games_formatted =
+ games_in_common.iter().sorted_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())).join("\n");
+
+ if games_formatted.is_empty() {
+ games_formatted = "**LITERALLY NOTHING**".to_owned();
+ }
+
+ util::reply(ctx, games_formatted).await?;
+
+ Ok(())
+}
+
+async fn load_spreadsheet(client: &reqwest::Client) -> Result<Vec<Vec<String>>> {
+ let mut u = SPREADSHEET_URL.clone();
+
+ u.query_pairs_mut()
+ .append_pair("ranges", &format!("a1:{}", &CONFIG.sheets.max_column))
+ .append_pair("valueRenderOption", "FORMATTED_VALUE")
+ .append_pair("majorDimension", "COLUMNS")
+ .append_pair("key", &CONFIG.sheets.api_key);
+
+ let req = reqwest::Request::new(reqwest::Method::GET, u);
+ let resp = client.execute(req).await?;
+
+ #[derive(Deserialize)]
+ struct Resp {
+ #[serde(rename = "valueRanges")]
+ value_ranges: Vec<Inner>,
+ }
+
+ #[derive(Deserialize)]
+ struct Inner {
+ values: Vec<Vec<String>>,
+ }
+
+ let resp = resp.json::<Resp>().await?;
+
+ Ok(resp.value_ranges.into_iter().next().unwrap().values)
+}
+
+#[poise::command(
+ slash_command,
+ prefix_command,
+ guild_only,
+ category = "gaem",
+ aliases("updategame")
+)]
+pub async fn updategaem(ctx: PoiseContext<'_>, user: Option<String>) -> anyhow::Result<()> {
+ use regex::Regex;
+ use std::borrow::Borrow;
+
+ let client = {
+ let data = ctx.serenity_context().data.read().await;
+ data.get::<HttpKey>().unwrap().clone()
+ };
+
+ let user = match user {
+ None => ctx.author().id,
+ Some(user) => {
+ let guild = ctx
+ .channel_id()
+ .to_channel(&ctx)
+ .await?
+ .guild()
+ .ok_or(anyhow!("couldn't find guild"))?;
+
+ let guild = guild.guild(&ctx).ok_or(anyhow!("couldn't find guild"))?;
+
+ get_user_id(guild.borrow(), user).map_err(anyhow::Error::from)?
+ },
+ };
+
+ debug!("parsed userid {:?}", user);
+
+ let username = match DISCORD_MAP.get(&user) {
+ Some(s) => s,
+ None => {
+ util::reply(ctx, "WHO THE FUCK ARE YE").await?;
+ return Ok(());
+ },
+ };
+
+ let steam_id = match STEAM_MAP.get(&user) {
+ Some(u) => u,
+ None => {
+ util::reply(ctx, "WHO ARE YE ON STEAM").await?;
+ return Ok(());
+ },
+ };
+
+ let spreadsheet = load_spreadsheet(&client).await?;
+
+ let user_column = (0..spreadsheet.len())
+ .find(|x| spreadsheet[*x][0].to_lowercase() == username.to_lowercase());
+
+ let user_column = match user_column {
+ Some(c) => &spreadsheet[c][1..],
+ None => {
+ util::reply(ctx, "YER NOT IN THE SPREADSHEET").await?;
+ return Ok(());
+ },
+ };
+
+ lazy_static! {
+ static ref APPID_REGEX: Regex = Regex::new(r#"(?i)^\s*app\s*id\s*$"#).unwrap();
+ }
+
+ let appid_column = (0..spreadsheet.len()).find(|x| APPID_REGEX.is_match(&spreadsheet[*x][0]));
+
+ let appid_column = match appid_column {
+ Some(c) => &spreadsheet[c][1..],
+ None => {
+ error!("didn't find an appid column in the spreadsheet");
+ util::reply(ctx, "SPREADSHEET BROKE").await?;
+ return Ok(());
+ },
+ };
+
+ let missing_appids = (0..user_column.len())
+ .filter_map(|x| user_column[x].parse::<GameStatus>().ok().map(|s| (x, s)))
+ .filter(|(_, s)| *s == GameStatus::Unknown || *s == GameStatus::NotOwned)
+ .filter_map(|(x, _)| {
+ appid_column.get(x).and_then(|s| s.parse::<u64>().ok().map(|appid| (appid, x)))
+ });
+
+ let mut u = Url::parse("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1")?;
+
+ u.query_pairs_mut()
+ .append_pair("key", &CONFIG.steam_api_key)
+ .append_pair("include_played_free_games", "1")
+ .append_pair("steamid", &steam_id.to_string());
+
+ #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
+ struct SteamResp {
+ response: SteamInner,
+ }
+
+ #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
+ struct SteamInner {
+ games: Vec<SteamGameEntry>,
+ }
+
+ #[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ struct SteamGameEntry {
+ #[serde(rename = "appid")]
+ app_id: u64,
+
+ #[serde(rename = "playtime_forever")]
+ play_time: u64,
+ }
+
+ let client = {
+ let data = ctx.serenity_context().data.read().await;
+ data.get::<HttpKey>().unwrap().clone()
+ };
+
+ let games_owned = client
+ .get(u)
+ .send()
+ .await?
+ .json::<SteamResp>()
+ .await?
+ .response
+ .games
+ .into_iter()
+ .map(|ge| ge.app_id)
+ .collect::<FnvHashSet<_>>();
+
+ let found_games = missing_appids
+ .filter_map(|(ai, x)| {
+ if games_owned.contains(&ai) {
+ Some(&spreadsheet[0][x + 1])
+ } else {
+ None
+ }
+ })
+ .join("\n");
+
+ if !found_games.is_empty() {
+ let n_missing = found_games.chars().filter(|x| *x == '\n').count() + 1;
+ util::reply(
+ ctx,
+ format!(
+ "{n_missing} games owned on steam that are missing from the list:\n{found_games}"
+ ),
+ )
+ .await?;
+ } else {
+ util::reply(ctx, "up to date").await?;
+ }
+
+ Ok(())
+}