diff options
Diffstat (limited to 'src/commands/game.rs')
| -rw-r--r-- | src/commands/game.rs | 572 |
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(()) +} |
