use std::{ fs, iter, path::PathBuf, result::Result as StdResult, str::{ self, FromStr, }, }; use anyhow::{ anyhow, Context, }; use fnv::{ FnvHashMap, FnvHashSet, }; use grate::tracing; use itertools::Itertools; use lazy_static::lazy_static; use serde::Deserialize; use serenity::model::{ guild::Guild, id::UserId, }; use tap::Pipe; use url::Url; use crate::{ bot::HttpKey, util, PoiseContext, 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, #[serde(rename = "discord")] discord_user_id: u64, } lazy_static! { static ref USER_MAP_STR: String = { let default_path = PathBuf::from_str("user_id_mapping.json").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::>(); tracing::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 = anyhow::Error; fn from_str(s: &str) -> anyhow::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))) } } } pub fn commands() -> Vec> { vec![installedgame(), ownedgame(), game(), updategaem()] } /// Find a game everyone can play (marked installed). /// /// Looks up users in the general voice channel if no users are passed. #[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 } /// Find a game everyone owns. /// /// Looks up users in the general voice channel if no users are passed. #[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>(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)), } } /// Find a game everyone can play (marked installed). /// /// Looks up users in the general voice channel if no users are passed. #[poise::command(prefix_command, guild_only, category = "gaem", aliases("gaem"))] async fn game( ctx: PoiseContext<'_>, #[description = "other users to include"] args: util::RestVec, ) -> anyhow::Result<()> { _game(ctx, args.into_inner(), GameStatus::Installed).await } async fn _game( ctx: PoiseContext<'_>, user_args: Vec, 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 { tracing::error!("failed retrieving guild"); return None; }; get_user_id(guild.borrow(), &u) }; tracing::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() { tracing::info!("user {uid} is not recognized"); } 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(&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::>(); } users }; let inferred = users.is_empty(); if inferred && users.len() < 2 || !inferred && users.is_empty() { tracing::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::().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::reply(ctx, games_formatted).await?; Ok(()) } async fn load_spreadsheet(client: &reqwest::Client) -> anyhow::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) } /// Find games that are out-of-date on the spreadsheet. #[poise::command(prefix_command, guild_only, category = "gaem", aliases("updategame"))] pub async fn updategaem(ctx: PoiseContext<'_>, user: Option) -> anyhow::Result<()> { use regex::Regex; use std::borrow::Borrow; let client = { let data = ctx.serenity_context().data.read().await; data.get::().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)? }, }; tracing::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 => { tracing::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::().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.serenity_context().data.read().await; data.get::().unwrap().clone() }; let games_owned = client .get(u) .send() .await? .error_for_status() .context("retrieve steam info http status")? .json::() .await .context("decode steam resp")?; let games_owned = games_owned.response.games.into_iter().map(|ge| ge.app_id).collect::>(); tracing::debug!("user owns {} steam games", games_owned.len()); 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 aren't marked on the list:\n{found_games}" ), ) .await?; } else { util::reply(ctx, "up to date").await?; } Ok(()) }