use std::{ convert::Infallible, fs, iter, path::PathBuf, result::Result as StdResult, str::{ self, FromStr, }, }; use anyhow::{ anyhow, Error, }; use fnv::{ FnvHashMap, FnvHashSet, }; use itertools::Itertools; use lazy_static::lazy_static; use log::{ debug, error, info, }; use serde::Deserialize; use serenity::{ framework::standard::{ macros::{ command, group, }, ArgError, Args, CommandError, CommandResult, }, futures::TryFutureExt, model::{ channel::Message, guild::Guild, id::UserId, }, prelude::*, }; use tap::Pipe; use url::Url; use crate::{ bot::HttpKey, util, Result, CONFIG, }; #[group] #[commands(game, installedgame, ownedgame, updategaem)] pub struct Game; 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, #[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 = { let v: Vec = serde_json::from_str(&USER_MAP_STR).unwrap(); let result = v .into_iter() .map(|ui| { let UserInfo { name, profile, } = ui; (name, profile) }) .collect::>(); info!( "loaded user info for {} users ({:#?})", result.len(), result.keys().collect::>() ); result }; static ref DISCORD_MAP: FnvHashMap = { USER_INFO_MAP .clone() .into_iter() .map(|(name, profile)| (UserId::new(profile.discord_user_id), name)) .collect::>() }; static ref STEAM_MAP: FnvHashMap = { USER_INFO_MAP .clone() .into_iter() .filter_map(|(_, profile)| { profile.steam_id.map(|sid| (UserId::new(profile.discord_user_id), sid)) }) .collect::>() }; static ref ALPHABET: Vec = (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 = Error; fn from_str(s: &str) -> Result { 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))) } } } #[command] #[aliases("installedgaem")] pub async fn installedgame(ctx: &Context, msg: &Message, args: Args) -> CommandResult { _game(ctx, msg, args, GameStatus::Installed).await } #[command] #[aliases("ownedgaem")] pub async fn ownedgame(ctx: &Context, msg: &Message, args: Args) -> CommandResult { _game(ctx, msg, args, 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>(g: &Guild, s: S) -> StdResult { 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::>(); match opts.len() { 0 => Err(UserLookupError::NotFound), 1 => Ok(opts.into_iter().next().unwrap()), x => Err(UserLookupError::Ambiguous(x)), } } #[command] #[aliases("gaem")] async fn game(ctx: &Context, msg: &Message, args: Args) -> CommandResult { _game(ctx, msg, args, GameStatus::Installed).await } async fn _game( ctx: &Context, msg: &Message, mut args: Args, min_status: GameStatus, ) -> CommandResult { let users = { let guild = msg.channel_id.to_channel(&ctx).await?.guild().ok_or(anyhow!("couldn't find guild"))?; let user_args: Vec = if args.rest().is_empty() { Vec::new() } else { args.quoted().iter::().collect::, ArgError>>()? }; use serenity::futures::StreamExt; 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 { Err(UserLookupError::NotFound) => { let _ = util::send( ctx, msg.channel_id, &format!("didn't recognize {}", &u), msg.tts, ) .await; None }, Ok(x) => Some(x), Err(UserLookupError::Ambiguous(x)) => { let _ = util::send( ctx, msg.channel_id, &format!("too many matches ({}) for {}", x, &u), msg.tts, ) .await; None }, } } }) .filter_map(|uid| async move { let res = DISCORD_MAP.get(&uid).map(|s| s.to_lowercase()); if res.is_none() { info!("user {} is not recognized", uid); } res }) .collect::>() .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::>(); let channel = pairs.get(&msg.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::>(); } 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::send(ctx, msg.channel_id, "yer too lonely", msg.tts).await?; return Ok(()); } let client = { let data = ctx.data.read().await; data.get::().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::>(); 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::>(); (1..data[*col].len()).for_each(|i| { let status = &data_ref[*col][i].parse::().unwrap_or(GameStatus::Unknown); let game = &data_ref[0][i]; game_map.get_mut(status).unwrap().insert(game); }); (user, game_map) }) .collect::>(); let statuses = vec![ GameStatus::Installed, GameStatus::NotOwned, GameStatus::NotInstalled, GameStatus::Unknown, ] .into_iter() .filter(|s| s <= &min_status) .collect::>(); let mut games_in_common = { let game_map = user_games.values().next().unwrap(); statuses.iter().fold(iter::empty().collect::>(), |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::>(), |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::send(ctx, msg.channel_id, &games_formatted, msg.tts).await?; Ok(()) } async fn load_spreadsheet(client: &reqwest::Client) -> Result>> { 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, } #[derive(Deserialize)] struct Inner { values: Vec>, } let resp = resp.json::().await?; Ok(resp.value_ranges.into_iter().next().unwrap().values) } #[command] #[aliases("updategame")] pub async fn updategaem(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { use regex::Regex; let client = { let data = ctx.data.read().await; data.get::().unwrap().clone() }; let arg_user = args.single_quoted::(); let user = if arg_user.is_err() { msg.author.id } else { use std::borrow::Borrow; let guild = msg.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(), arg_user.unwrap()).map_err(Error::from)? }; debug!("parsed userid {:?}", user); let username = match DISCORD_MAP.get(&user) { Some(s) => s, None => { return util::send(ctx, msg.channel_id, "WHO THE FUCK ARE YE", msg.tts) .map_err(CommandError::from) .await; }, }; let steam_id = match STEAM_MAP.get(&user) { Some(u) => u, None => { return util::send(ctx, msg.channel_id, "WHO ARE YE ON STEAM", msg.tts) .map_err(CommandError::from) .await; }, }; 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 => { return util::send(ctx, msg.channel_id, "YER NOT IN THE SPREADSHEET", msg.tts) .map_err(CommandError::from) .await; }, }; 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"); return util::send(ctx, msg.channel_id, "SPREADSHEET BROKE", msg.tts) .map_err(CommandError::from) .await; }, }; let missing_appids = (0..user_column.len()) .filter_map(|x| user_column[x].parse::().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::().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, } #[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.data.read().await; data.get::().unwrap().clone() }; let games_owned = client .get(u) .send() .await? .json::() .await? .response .games .into_iter() .map(|ge| ge.app_id) .collect::>(); 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() { util::send( ctx, msg.channel_id, &format!( "{} games owned on steam that are missing from the list:\n{}", found_games.chars().filter(|x| *x == '\n').count() + 1, found_games ), msg.tts, ) .await?; } else { util::send(ctx, msg.channel_id, "up to date", msg.tts).await?; } Ok(()) }