stat_summoner/module/loop_module/utils.rs
1use chrono::Utc;
2use futures::StreamExt;
3use mongodb::{bson::doc, Collection};
4use poise::serenity_prelude::{self as serenity, CreateEmbed, CreateMessage, Http};
5use regex::Regex;
6use select::document::Document;
7use select::predicate::{Class, Name};
8use serde_json::Value;
9use std::{collections::HashMap, sync::Arc};
10
11use crate::{
12 models::{
13 data::{CoreBuildData, EmojiId, RunesData, SummonerFollowedData},
14 error::Error,
15 },
16 riot_api::{get_matchs_id, get_matchs_info},
17 utils::*,
18};
19
20/// ⚙️ **Function**: Extracts relevant match details for a given summoner from the match information.
21///
22/// This function retrieves detailed information about a match, focusing on the summoner specified by their `summoner_id`.
23/// It validates the game mode, identifies the summoner's performance, and compares their stats with the enemy team in each role (TOP, JUNGLE, MIDDLE, BOTTOM, UTILITY).
24///
25/// # Parameters:
26/// - `match_info`: A reference to a `Value` (from the `serde_json` crate) containing the entire match data fetched from the Riot API.
27/// - `summoner_id`: A string slice representing the summoner's ID, used to locate their stats in the match data.
28///
29/// # Returns:
30/// - `Option<Value>`: Returns a JSON object containing the match result (Victory or Defeat) and detailed role-based stats comparisons, or `None` if the game mode is invalid or the data is not available.
31///
32/// # Example:
33/// This function is typically used to extract and format match details for reporting to a Discord channel:
34///
35/// ```rust
36/// let match_details = get_match_details(&match_info, summoner_id);
37/// if let Some(details) = match_details {
38/// // Process match details for further use
39/// }
40/// ```
41///
42/// # Notes:
43/// - The function first checks if the game mode is valid using `is_valid_game_mode`. If the game mode is invalid, the function returns `None`.
44/// - It then searches for the summoner in the participants list and identifies their team and match result (Victory or Defeat).
45/// - The function separates the participants into two teams (the summoner's team and the enemy team) and compares stats for each role.
46/// - It generates JSON-formatted role matchups comparing stats between the summoner's team and their opponents for each role.
47pub fn get_match_details(match_info: &Value, puuid: &str) -> Option<Value> {
48 let queue_id = match_info["info"]["queueId"].as_i64().unwrap_or(-1);
49 let (game_duration_minutes, game_duration_secondes) =
50 seconds_to_time(match_info["info"]["gameDuration"].as_u64().unwrap_or(0));
51 let game_duration_string = format!("{}:{}", game_duration_minutes, game_duration_secondes);
52 // utilise QUEUE_ID_MAP qui est une constante dans models/constants.rs qui contient une liste de game modes faisant correspondre id -> game mode en str
53 let game_mode = get_game_mode(queue_id);
54
55 let participants = match_info["info"]["participants"].as_array()?;
56 let participant = participants
57 .iter()
58 .find(|p| p["puuid"].as_str().unwrap_or("") == puuid)?;
59
60 let team_id = participant["teamId"].as_i64().unwrap_or(0);
61 let win = participant["win"].as_bool().unwrap_or(false);
62 let game_result = if win { "Victory" } else { "Defeat" };
63
64 let mut team_participants: HashMap<String, &Value> = HashMap::new();
65 let mut enemy_participants: HashMap<String, &Value> = HashMap::new();
66
67 for p in participants {
68 let position = p["teamPosition"].as_str().unwrap_or("UNKNOWN").to_string();
69 let p_team_id = p["teamId"].as_i64().unwrap_or(0);
70 if p_team_id == team_id {
71 team_participants.insert(position.clone(), p);
72 } else {
73 enemy_participants.insert(position.clone(), p);
74 }
75 }
76
77 let roles = vec!["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
78
79 let mut matchups = Vec::new();
80
81 for role in roles {
82 if let (Some(team_p), Some(enemy_p)) =
83 (team_participants.get(role), enemy_participants.get(role))
84 {
85 let team_stats = extract_participant_stats(team_p);
86 let enemy_stats = extract_participant_stats(enemy_p);
87
88 let matchup = serde_json::json!({
89 "role": role,
90 "team": team_stats,
91 "enemy": enemy_stats
92 });
93
94 matchups.push(matchup);
95 }
96 }
97
98 Some(serde_json::json!({
99 "gameMode": game_mode,
100 "gameResult": game_result,
101 "gameDuration": game_duration_string,
102 "matchups": matchups
103 }))
104}
105
106/// ⚙️ **Function**: Creates a detailed embed for a player's match performance in Discord.
107///
108/// This asynchronous function generates a `CreateEmbed` object that includes detailed statistics
109/// of a player's match, such as the game mode, result, duration, and a role-by-role comparison
110/// of the player's team versus the enemy team. The embed is enriched with emojis and formatted
111/// data to make it visually appealing for Discord.
112///
113/// # Parameters:
114/// - `info_json`: A reference to a `Value` (from the `serde_json` crate) containing the match data fetched from the Riot API.
115/// - `player_name`: A string slice representing the player's name, used for the embed's title.
116/// - `collection_emoji`: A MongoDB `Collection` containing emoji mappings, which are used to enhance the embed with role and champion-specific emojis.
117///
118/// # Returns:
119/// - `CreateEmbed`: Returns a `CreateEmbed` object containing the formatted match data, including role-based comparisons and game metadata, ready to be sent to a Discord channel.
120///
121/// # Example:
122/// This function is typically used to send detailed match information to a Discord channel:
123///
124/// ```rust
125/// let embed = create_embed_loop(&info_json, "PlayerName", collection_emoji).await;
126/// // Send the embed to a Discord channel using your bot's message-sending logic
127/// ```
128///
129/// # Notes:
130/// - The function begins by extracting key game metadata (game mode, result, and duration) from `info_json`.
131/// - Based on the match result, it selects appropriate emojis and colors for the embed.
132/// - The function then constructs the title and proceeds to iterate over the available role-based matchups, comparing the stats of the player's team with the enemy team for each role (TOP, JUNGLE, MIDDLE, BOTTOM, UTILITY).
133/// - Role and champion names are replaced by their corresponding emojis from the `collection_emoji`, retrieved using the `get_emoji` function.
134/// - The function formats team and enemy stats (kills, deaths, assists, CS, gold, vision score) for each role and adds them as fields in the embed.
135/// - It returns a fully constructed `CreateEmbed` ready to be sent in a Discord message.
136pub async fn create_embed_loop(
137 info_json: &Value,
138 player_name: &str,
139 collection_emoji: Collection<EmojiId>,
140) -> CreateEmbed {
141 let game_mode = info_json["gameMode"].as_str().unwrap_or("Unknown");
142 let game_result = info_json["gameResult"].as_str().unwrap_or("Unknown");
143 let game_duration = info_json["gameDuration"].as_str().unwrap_or("00:00");
144 let game_result_emoji = if game_result == "Victory" {
145 "🏆"
146 } else {
147 "❌"
148 };
149 let game_result_thumbnail = if game_result == "Victory" {
150 "https://i.postimg.cc/CxwjnWVk/pngegg.png"
151 } else {
152 "https://i.postimg.cc/XJBF0WwS/pngwing-com.png"
153 };
154 let color: i32 = if game_result == "Victory" {
155 0x00ff00
156 } else {
157 0xff0000
158 };
159
160 // Construct the embed title
161 let title = format!(
162 "**{}** - **{}: {} {} - {} **",
163 player_name, game_mode, game_result, game_result_emoji, game_duration
164 );
165
166 let roles_order = ["TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY"];
167 let mut matchups_by_role = std::collections::HashMap::new();
168 if let Some(matchups) = info_json["matchups"].as_array() {
169 for matchup in matchups {
170 if let Some(role) = matchup["role"].as_str() {
171 matchups_by_role.insert(role.to_uppercase(), matchup);
172 }
173 }
174 }
175 let mut embed = CreateEmbed::new()
176 .title(title)
177 .color(color)
178 .thumbnail(game_result_thumbnail);
179
180 for role in &roles_order {
181 if let Some(matchup) = matchups_by_role.get(&role.to_uppercase()) {
182 let team_player = &matchup["team"];
183 let enemy_player = &matchup["enemy"];
184 let role_label = match *role {
185 "TOP" => format!(
186 "**{} TOP**\n",
187 get_emoji(collection_emoji.clone(), "position", "TOP")
188 .await
189 .unwrap_or("🔼".to_string())
190 ),
191 "JUNGLE" => format!(
192 "**{} JUNGLE**\n",
193 get_emoji(collection_emoji.clone(), "position", "JUNGLE")
194 .await
195 .unwrap_or("🌲".to_string())
196 ),
197 "MIDDLE" => format!(
198 "**{} MIDDLE**\n",
199 get_emoji(collection_emoji.clone(), "position", "MIDDLE")
200 .await
201 .unwrap_or("🛣️".to_string())
202 ),
203 "BOTTOM" => format!(
204 "**{} BOTTOM**\n",
205 get_emoji(collection_emoji.clone(), "position", "BOTTOM")
206 .await
207 .unwrap_or("🔽".to_string())
208 ),
209 "UTILITY" => format!(
210 "**{} SUPPORT**\n",
211 get_emoji(collection_emoji.clone(), "position", "SUPPORT")
212 .await
213 .unwrap_or("🛡️".to_string())
214 ),
215 _ => "**UNKNOWN**\n".to_string(),
216 };
217
218 // Team player stats
219 let team_stats = format!(
220 "{} **{}**\nK/D/A: **{}/{}/{}** | CS: **{}** | Gold: {} | Vision: {}",
221 get_emoji(
222 collection_emoji.clone(),
223 "champions",
224 team_player["championName"].as_str().unwrap_or("Unknown")
225 )
226 .await
227 .unwrap_or(
228 team_player["championName"]
229 .as_str()
230 .unwrap_or("Unknown")
231 .to_string()
232 ),
233 team_player["summonerName"].as_str().unwrap_or("Unknown"),
234 team_player["kills"].as_u64().unwrap_or(0),
235 team_player["deaths"].as_u64().unwrap_or(0),
236 team_player["assists"].as_u64().unwrap_or(0),
237 team_player["totalFarm"].as_u64().unwrap_or(0),
238 format_gold_k(team_player["goldEarned"].as_u64().unwrap_or(0)),
239 team_player["visionScore"].as_u64().unwrap_or(0)
240 );
241
242 // Enemy player stats
243 let enemy_stats = format!(
244 "{} **{}**\nK/D/A: **{}/{}/{}** | CS: **{}** | Gold: {} | Vision: {}",
245 get_emoji(
246 collection_emoji.clone(),
247 "champions",
248 enemy_player["championName"].as_str().unwrap_or("Unknown")
249 )
250 .await
251 .unwrap_or(
252 enemy_player["championName"]
253 .as_str()
254 .unwrap_or("Unknown")
255 .to_string()
256 ),
257 enemy_player["summonerName"].as_str().unwrap_or("Unknown"),
258 enemy_player["kills"].as_u64().unwrap_or(0),
259 enemy_player["deaths"].as_u64().unwrap_or(0),
260 enemy_player["assists"].as_u64().unwrap_or(0),
261 enemy_player["totalFarm"].as_u64().unwrap_or(0),
262 format_gold_k(enemy_player["goldEarned"].as_u64().unwrap_or(0)),
263 enemy_player["visionScore"].as_u64().unwrap_or(0)
264 );
265
266 // Combine team and enemy stats
267 let field_value = format!("{}\n{}", team_stats, enemy_stats);
268
269 // Add the field to the embed
270 embed = embed.field(role_label, field_value, false);
271 }
272 }
273
274 embed
275}
276
277/// ⚙️ **Function**: Extracts key participant statistics from a match for a given player.
278///
279/// This function retrieves important statistics for a participant in a League of Legends match, such as their summoner name,
280/// champion name, kills, deaths, assists, total farm, gold earned, and vision score. The extracted stats are returned as a JSON object (`serde_json::Value`).
281///
282/// # Parameters:
283/// - `p`: A reference to a `serde_json::Value` object representing a participant in the match. This object contains all of the participant's stats and data.
284///
285/// # Returns:
286/// - `Value`: Returns a JSON object containing the player's stats, including their summoner name, champion name, K/D/A (kills, deaths, assists),
287/// total farm (minions and neutral monsters killed), gold earned, gold per minute, and vision score.
288///
289/// # Example:
290/// This function is used to format and extract individual player stats from the match data:
291///
292/// ```rust
293/// let player_stats = extract_participant_stats(&participant);
294/// println!("{}", player_stats["summonerName"]);
295/// ```
296///
297/// # Notes:
298/// - The summoner's name is prioritized over their Riot ID game name, but if the summoner name is missing, the Riot ID is used as a fallback.
299/// - Total farm is calculated as the sum of minions killed and neutral monsters killed.
300/// - The stats returned include the summoner's name, champion, K/D/A, farm, gold, gold per minute, and vision score, which are useful for comparing performance across teams.
301fn extract_participant_stats(p: &Value) -> Value {
302 let riot_id_game_name = p["riotIdGameName"].as_str().unwrap_or("Unknown");
303 let summoner_name = if p["summonerName"].as_str().unwrap_or("Unknown").is_empty() {
304 riot_id_game_name
305 } else {
306 p["summonerName"].as_str().unwrap_or("Unknown")
307 };
308 let champion_name = p["championName"].as_str().unwrap_or("Unknown");
309 let kills = p["kills"].as_u64().unwrap_or(0);
310 let deaths = p["deaths"].as_u64().unwrap_or(0);
311 let assists = p["assists"].as_u64().unwrap_or(0);
312 let total_minions_killed = p["totalMinionsKilled"].as_u64().unwrap_or(0);
313 let neutral_minions_killed = p["neutralMinionsKilled"].as_u64().unwrap_or(0);
314 let total_farm = total_minions_killed + neutral_minions_killed;
315 let gold_earned = p["goldEarned"].as_u64().unwrap_or(0);
316 let vision_score = p["visionScore"].as_u64().unwrap_or(0);
317
318 serde_json::json!({
319 "summonerName": summoner_name,
320 "championName": champion_name,
321 "kills": kills,
322 "deaths": deaths,
323 "assists": assists,
324 "totalFarm": total_farm,
325 "goldEarned": gold_earned,
326 "visionScore": vision_score
327 })
328}
329
330/// ⚙️ **Function**: Formats the amount of gold earned in a match into a more readable "k" notation when appropriate.
331///
332/// This function takes an amount of gold as input and formats it into a human-readable string. If the amount is less than 1000,
333/// it returns the gold value as a simple string. If the gold is 1000 or more, it formats the value in "k" notation (e.g., 1500 becomes "1.5k").
334///
335/// # Parameters:
336/// - `gold`: A `u64` value representing the amount of gold earned by a player in a match.
337///
338/// # Returns:
339/// - `String`: A formatted string representing the gold amount. If the amount is less than 1000, it returns the value as is.
340/// For amounts equal to or greater than 1000, it returns a string in "k" notation (e.g., "1k", "1.5k") with a comma used as the decimal separator.
341///
342/// # Example:
343/// ```rust
344/// let formatted_gold = format_gold_k(1500);
345/// assert_eq!(formatted_gold, "1,5k");
346/// ```
347///
348/// # Notes:
349/// - For gold values with no fractional part, the result will omit the decimal point (e.g., 1000 will be formatted as "1k" instead of "1.0k").
350/// - The function uses a comma to separate the decimal part, following European formatting conventions.
351fn format_gold_k(gold: u64) -> String {
352 if gold < 1000 {
353 gold.to_string()
354 } else {
355 let gold_f64 = (gold as f64) / 1000.0;
356 if gold_f64.fract() == 0.0 {
357 format!("{}k", gold_f64 as u64)
358 } else {
359 let formatted = format!("{:.1}", gold_f64).replace('.', ",");
360 format!("{}k", formatted)
361 }
362 }
363}
364
365/// ⚙️ **Function**: Retrieves all followed summoners from the database.
366///
367/// This asynchronous function queries the "follower_summoner" collection in the "stat-summoner" MongoDB database
368/// to retrieve all documents representing followed summoners. It collects each `SummonerFollowedData` into a vector.
369///
370/// # Parameters:
371/// - `collection`: A reference to the MongoDB collection containing `SummonerFollowedData` documents.
372///
373/// # Returns:
374/// - `Result<Vec<SummonerFollowedData>, mongodb::error::Error>`: A vector of followed summoners if successful, or an error if the query fails.
375///
376/// # ⚠️ Notes:
377/// - Prints an error message in French if a document retrieval fails.
378/// - Ensure that the `SummonerFollowedData` struct aligns with the collection's document structure.
379///
380/// # Example:
381/// ```rust
382/// let summoners = get_followed_summoners(&collection).await?;
383/// println!("Retrieved {} summoners.", summoners.len());
384/// ```
385pub async fn get_followed_summoners(
386 collection: &Collection<SummonerFollowedData>,
387) -> Result<Vec<SummonerFollowedData>, mongodb::error::Error> {
388 let mut cursor = collection.find(doc! {}).await?;
389 let mut followed_summoners = Vec::new();
390
391 while let Some(result) = cursor.next().await {
392 match result {
393 Ok(followed_summoner) => {
394 followed_summoners.push(followed_summoner);
395 }
396 Err(e) => {
397 log::error!("Erreur lors de la récupération d'un document : {:?}", e);
398 }
399 }
400 }
401
402 Ok(followed_summoners)
403}
404
405/// ⚙️ **Function**: Processes a followed summoner by checking if their follow time has expired or if they have played a new match.
406///
407/// This asynchronous function handles the logic for a followed summoner. It checks if the follow time has expired and removes the summoner from the database if necessary. If the follow time is still valid, it checks for new matches and updates the summoner's information accordingly.
408///
409/// # Parameters:
410/// - `collection`: A reference to a MongoDB `Collection<SummonerFollowedData>` that stores the followed summoners' data.
411/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct containing the summoner's information, including their follow duration and last match details.
412/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
413/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
414/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to enrich the Discord embeds with custom emojis for roles and champions.
415///
416/// # Returns:
417/// - `Result<(), Error>`: Returns `Ok(())` if the summoner was successfully processed (either by removing them from the database or updating their match info), or an error if something went wrong.
418///
419/// # Example:
420/// This function is typically called as part of a loop or scheduled task that checks the status of followed summoners:
421///
422/// ```rust
423/// let result = process_followed_summoner(collection, &followed_summoner, riot_api_key, http.clone(), collection_emojis).await;
424/// if result.is_err() {
425/// // Handle error (e.g., log failure or retry)
426/// }
427/// ```
428///
429/// # Notes:
430/// - The function begins by checking if the follow time for the summoner has expired using the `is_follow_time_expired` function.
431/// - If the follow time has expired, the summoner is removed from the MongoDB collection by calling `delete_follower`.
432/// - If the summoner is still being followed, the function calls `update_follower_if_new_match` to check for new matches and potentially send an update to the associated Discord channel.
433/// - This function ensures that summoners are only followed for the specified duration and that Discord channels are updated with relevant match information during the follow period.
434pub async fn process_followed_summoner(
435 collection: &Collection<SummonerFollowedData>,
436 followed_summoner: &SummonerFollowedData,
437 riot_api_key: &str,
438 http: Arc<Http>,
439 collection_emojis: Collection<EmojiId>,
440) -> Result<(), Error> {
441 if is_follow_time_expired(followed_summoner) {
442 delete_follower(collection, followed_summoner).await?;
443 } else {
444 update_follower_if_new_match(
445 collection,
446 followed_summoner,
447 riot_api_key,
448 http,
449 collection_emojis,
450 )
451 .await?;
452 }
453 Ok(())
454}
455
456/// ⚙️ **Function**: Determines if the follow time for a summoner has expired.
457///
458/// This function checks whether the current timestamp exceeds the stored follow end time for a summoner.
459/// It parses the `time_end_follow` field from the `SummonerFollowedData` struct, compares it to the current UTC timestamp,
460/// and returns `true` if the follow time has expired, or `false` otherwise.
461///
462/// # Parameters:
463/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, which contains information about the summoner, including when the follow period ends.
464///
465/// # Returns:
466/// - `bool`: Returns `true` if the current time is greater than the stored `time_end_follow`, meaning the follow period has expired. Returns `false` if the follow period is still active.
467///
468/// # Example:
469/// This function is used to determine whether a summoner should be removed from the list of followed summoners.
470///
471/// ```rust
472/// let expired = is_follow_time_expired(&followed_summoner);
473/// if expired {
474/// // Remove summoner from database
475/// }
476/// ```
477///
478/// # Notes:
479/// - The function uses UTC time for comparison and assumes the `time_end_follow` is a valid timestamp that can be parsed into an `i64`. If parsing fails, it defaults to 0, which will always result in `true`.
480fn is_follow_time_expired(followed_summoner: &SummonerFollowedData) -> bool {
481 let time_end_follow = followed_summoner
482 .time_end_follow
483 .parse::<i64>()
484 .unwrap_or(0);
485 let current_timestamp = Utc::now().timestamp();
486 current_timestamp > time_end_follow
487}
488
489/// ⚙️ **Function**: Deletes a followed summoner from the database.
490///
491/// This asynchronous function removes a summoner from the `follower_summoner` collection in MongoDB based on their `puuid`.
492/// It logs the deletion action and ensures the summoner is no longer tracked in the database.
493///
494/// # Parameters:
495/// - `collection`: A reference to the MongoDB `Collection<SummonerFollowedData>`, used to interact with the database and delete the summoner data.
496/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, representing the summoner that is being deleted. The deletion is based on the summoner's `puuid`.
497///
498/// # Returns:
499/// - `Result<(), mongodb::error::Error>`: Returns an empty result if successful, or an error if the deletion fails.
500///
501/// # Example:
502/// This function is typically called when the follow time for a summoner has expired, and they need to be removed from the database:
503///
504/// ```rust
505/// delete_follower(&collection, &followed_summoner).await?;
506/// ```
507///
508/// # Notes:
509/// - The `puuid` field is used as the unique identifier for deletion from the MongoDB collection.
510/// - The function logs the `puuid` of the summoner being deleted using `log::info!`, which outputs the message to the standard log stream.
511async fn delete_follower(
512 collection: &Collection<SummonerFollowedData>,
513 followed_summoner: &SummonerFollowedData,
514) -> Result<(), mongodb::error::Error> {
515 log::info!("Suppression de {}", followed_summoner.puuid);
516 collection
517 .delete_one(
518 doc! { "puuid": &followed_summoner.puuid, "guild_id": &followed_summoner.guild_id },
519 )
520 .await?;
521 Ok(())
522}
523
524/// ⚙️ **Function**: Updates a followed summoner's last match ID and sends a Discord update if a new match is detected.
525///
526/// This asynchronous function checks if a followed summoner has played a new match. If a new match is detected,
527/// it updates the summoner's last match ID in the MongoDB collection and sends a match update to the appropriate Discord channel.
528///
529/// # Parameters:
530/// - `collection`: A reference to a MongoDB `Collection<SummonerFollowedData>` that stores the followed summoners' data.
531/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct containing the summoner's information, including their PUUID, summoner ID, and last match ID.
532/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
533/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
534/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to enhance the Discord embed with custom emojis for roles and champions.
535///
536/// # Returns:
537/// - `Result<(), Error>`: Returns `Ok(())` if the last match ID was successfully updated and the match update was sent to Discord, or an error if something went wrong.
538///
539/// # Example:
540/// This function is typically called periodically to check if a followed summoner has played a new match:
541///
542/// ```rust
543/// let result = update_follower_if_new_match(collection, &followed_summoner, riot_api_key, http.clone(), collection_emojis).await;
544/// if result.is_err() {
545/// // Handle error (e.g., log failure or retry)
546/// }
547/// ```
548///
549/// # Notes:
550/// - The function begins by creating an HTTP client using `reqwest` and fetching the latest match ID for the summoner using the `get_latest_match_id` function.
551/// - If the new match ID is different from the stored `last_match_id`, the function updates the MongoDB collection with the new match ID.
552/// - Once the database is updated, the function calls `send_match_update_to_discord` to send a match update to the Discord channel associated with the summoner.
553/// - This function ensures that the Discord server is notified whenever the summoner completes a new match, keeping followers updated in real time.
554async fn update_follower_if_new_match(
555 collection: &Collection<SummonerFollowedData>,
556 followed_summoner: &SummonerFollowedData,
557 riot_api_key: &str,
558 http: Arc<Http>,
559 collection_emojis: Collection<EmojiId>,
560) -> Result<(), Error> {
561 let puuid = &followed_summoner.puuid;
562 let last_match_id = &followed_summoner.last_match_id;
563 let guild_id = &followed_summoner.guild_id;
564 let client = reqwest::Client::new();
565
566 let match_id_from_riot = get_latest_match_id(&client, puuid, riot_api_key).await?;
567
568 if last_match_id != &match_id_from_riot {
569 collection
570 .update_one(
571 doc! {
572 "puuid": puuid,
573 "guild_id": guild_id
574 },
575 doc! { "$set": { "last_match_id": &match_id_from_riot } },
576 )
577 .await?;
578 send_match_update_to_discord(
579 followed_summoner,
580 puuid,
581 &match_id_from_riot,
582 riot_api_key,
583 http,
584 collection_emojis,
585 )
586 .await?;
587 }
588 Ok(())
589}
590
591/// ⚙️ **Function**: Fetches the latest match ID for a given summoner using their PUUID.
592///
593/// This asynchronous function retrieves the most recent match ID for a summoner by making a request to the Riot API.
594/// It uses the summoner's `puuid` to query their match history and returns the match ID of the most recent game.
595///
596/// # Parameters:
597/// - `client`: A reference to the `reqwest::Client`, used to make HTTP requests to the Riot API.
598/// - `puuid`: A string slice representing the summoner's PUUID (a unique identifier for each player in Riot's system).
599/// - `riot_api_key`: A string slice representing the Riot API key, used for authorized requests.
600///
601/// # Returns:
602/// - `Result<String, Error>`: Returns the latest match ID as a string if successful, or an error if the request or retrieval fails.
603///
604/// # Example:
605/// This function is typically used to get the latest match ID for a summoner in order to check for new matches:
606///
607/// ```rust
608/// let latest_match_id = get_latest_match_id(&client, puuid, riot_api_key).await?;
609/// ```
610///
611/// # Notes:
612/// - The function calls `get_matchs_id` to retrieve the match history and then returns the first match in the list, which corresponds to the most recent match.
613/// - The `get_matchs_id` function is expected to return a vector of match IDs, from which the latest match (the first one) is extracted and returned.
614async fn get_latest_match_id(
615 client: &reqwest::Client,
616 puuid: &str,
617 riot_api_key: &str,
618) -> Result<String, Error> {
619 let matches = get_matchs_id(client, puuid, riot_api_key, 1).await?;
620 Ok(matches[0].clone())
621}
622
623/// ⚙️ **Function**: Sends a match update to a specific Discord channel for a followed summoner.
624///
625/// This asynchronous function fetches match information for a followed summoner from the Riot API,
626/// formats the details into an embed, and sends the embed as a message to the specified Discord channel.
627///
628/// # Parameters:
629/// - `followed_summoner`: A reference to a `SummonerFollowedData` struct, which contains the summoner's name and the ID of the Discord channel to which the match update should be sent.
630/// - `summoner_id`: A string slice representing the summoner's ID, used to identify the player's stats in the match.
631/// - `match_id`: A string slice representing the match ID, used to fetch match details from the Riot API.
632/// - `riot_api_key`: A string slice containing the Riot Games API key for authenticating the API request.
633/// - `http`: An `Arc<Http>` object used to send messages via the Discord API.
634/// - `collection_emojis`: A MongoDB `Collection` containing emoji mappings, used to add custom emojis to the embed for roles and champions.
635///
636/// # Returns:
637/// - `Result<(), Error>`: Returns `Ok(())` if the match update was successfully sent to the Discord channel, or an error if something went wrong.
638///
639/// # Example:
640/// This function is typically called after detecting that a followed summoner has completed a match:
641///
642/// ```rust
643/// let result = send_match_update_to_discord(&followed_summoner, summoner_id, match_id, riot_api_key, http.clone(), collection_emojis).await;
644/// if result.is_err() {
645/// // Handle error (e.g., log failure or retry)
646/// }
647/// ```
648///
649/// # Notes:
650/// - The function creates an HTTP client using `reqwest` to fetch match information from the Riot API.
651/// - It retrieves detailed match data using the `get_matchs_info` and `get_match_details` functions.
652/// - The function constructs a `CreateEmbed` object using the `create_embed_loop` function, which formats match statistics and adds emojis.
653/// - The embed is sent as a message to the Discord channel specified in the `followed_summoner` struct.
654/// - The Discord message is built using `CreateMessage` and sent asynchronously to the appropriate channel using the Discord API.
655async fn send_match_update_to_discord(
656 followed_summoner: &SummonerFollowedData,
657 puuid: &str,
658 match_id: &str,
659 riot_api_key: &str,
660 http: Arc<Http>,
661 collection_emojis: Collection<EmojiId>,
662) -> Result<(), Error> {
663 let client = reqwest::Client::new();
664 let info = get_matchs_info(&client, match_id, riot_api_key).await?;
665 let info_json = get_match_details(&info, puuid).unwrap();
666 let channel_id = serenity::model::id::ChannelId::new(followed_summoner.channel_id);
667 let embed = create_embed_loop(&info_json, &followed_summoner.name, collection_emojis).await;
668 let builder = CreateMessage::new().add_embed(embed);
669 let _ = channel_id.send_message(&http, builder).await;
670 Ok(())
671}
672
673/// ⚙️ **Function**: Fetches rune data for a specific champion from League of Graphs.
674///
675/// This asynchronous function retrieves the rune build information for a given champion
676/// by making an HTTP request to League of Graphs. It then parses the HTML response to
677/// extract the rune tables and returns the runes in the `RunesData` structure.
678///
679/// # Parameters:
680/// - `champion_id`: A string slice representing the champion's identifier, used to build the URL for fetching the rune information.
681///
682/// # Returns:
683/// - `Result<RunesData, Error>`: Returns a `RunesData` struct with the champion's rune information if successful, or an error if something goes wrong during the HTTP request or parsing.
684///
685/// # Example:
686/// This function is typically called to retrieve the rune data for a specific champion:
687///
688/// ```rust
689/// let runes = fetch_runes("Rammus").await?;
690/// println!("{:?}", runes);
691/// ```
692///
693/// # Notes:
694/// - The function makes an HTTP request to the League of Graphs page using the champion's ID to construct the URL.
695/// - It then parses the HTML to find the rune tables and extracts the relevant rune data.
696/// - The `extract_runes` function is used to process the HTML and return the rune information in the `RunesData` structure.
697/// - This function expects two rune tables (primary and secondary) to be present in the response, otherwise it will panic with an `unwrap()` error.
698pub async fn fetch_runes(champion_id: &str) -> Result<RunesData, Error> {
699 let url = format!(
700 "https://www.leagueofgraphs.com/champions/builds/{}",
701 champion_id
702 );
703 let client = reqwest::Client::new();
704 let res = client
705 .get(&url)
706 .header("User-Agent", "Mozilla/5.0")
707 .send()
708 .await?;
709 let body = res.text().await?;
710 let document = Document::from(body.as_str());
711
712 // Logique pour extraire les runes, en utilisant `RunesData` comme la structure finale
713 let first_rune_table = document
714 .find(Class("perksTableOverview"))
715 .next()
716 .ok_or_else(|| {
717 Box::<dyn std::error::Error + Send + Sync>::from(
718 "Erreur: Impossible de trouver la première table de runes",
719 )
720 })?;
721 let secondary_rune_table = document
722 .find(Class("perksTableOverview"))
723 .nth(1)
724 .ok_or_else(|| {
725 Box::<dyn std::error::Error + Send + Sync>::from(
726 "Erreur: Impossible de trouver la deuxième table de runes",
727 )
728 })?;
729
730 let runes = extract_runes(first_rune_table, secondary_rune_table);
731 Ok(runes)
732}
733
734/// ⚙️ **Function**: Fetches core build data for a specific champion from League of Graphs.
735///
736/// This asynchronous function retrieves the core build item information for a given champion
737/// by making an HTTP request to League of Graphs. It then parses the HTML response to locate
738/// the core build section and extracts the items used in the build.
739///
740/// # Parameters:
741/// - `champion_id`: A string slice representing the champion's identifier, used to build the URL for fetching the core build information.
742///
743/// # Returns:
744/// - `Result<CoreBuildData, Error>`: Returns a `CoreBuildData` struct containing the champion's core build items if successful, or an error if something goes wrong during the HTTP request or parsing.
745///
746/// # Example:
747/// This function is typically called to retrieve the core build data for a specific champion:
748///
749/// ```rust
750/// let core_build = fetch_core_build("Jinx").await?;
751/// println!("{:?}", core_build);
752/// ```
753///
754/// # Notes:
755/// - The function makes an HTTP request to the League of Graphs page using the champion's ID to construct the URL.
756/// - It parses the HTML to find the core build section by searching for an `h3` element containing the text "Core Build".
757/// - Once the core build header is found, the function searches for its parent element and the `iconsRow` div where the build items are listed.
758/// - The function calls `extract_core_build` to process the icons and return the items in the `CoreBuildData` structure.
759/// - If the core build header or the `iconsRow` div is not found, an error is returned.
760pub async fn fetch_core_build(champion_id: &str) -> Result<CoreBuildData, Error> {
761 let url = format!(
762 "https://www.leagueofgraphs.com/champions/builds/{}",
763 champion_id
764 );
765 let client = reqwest::Client::new();
766 let res = client
767 .get(&url)
768 .header("User-Agent", "Mozilla/5.0")
769 .send()
770 .await?;
771 let body = res.text().await?;
772 let document = select::document::Document::from(body.as_str());
773
774 if let Some(core_build_header) = document
775 .find(Name("h3"))
776 .find(|node| node.text().contains("Core Build"))
777 {
778 if let Some(parent_div) = core_build_header.parent() {
779 if let Some(icons_row) = parent_div.find(Class("iconsRow")).next() {
780 let core_build = extract_core_build(icons_row);
781 return Ok(core_build);
782 } else {
783 return Err(Box::from("Erreur: Impossible de trouver 'iconsRow'"));
784 }
785 } else {
786 return Err(Box::from(
787 "Erreur: Impossible de trouver le parent de 'Core Build'",
788 ));
789 }
790 } else {
791 return Err(Box::from(
792 "Erreur: Impossible de trouver le header 'Core Build'",
793 ));
794 }
795}
796
797/// ⚙️ **Function**: Extracts rune data from two HTML tables.
798///
799/// This function processes two HTML tables (representing primary and secondary runes) and extracts
800/// the rune images by filtering out those that are not visible (with `opacity: 0.2`) and by cleaning
801/// the `alt` attributes of the remaining images. The function then constructs a `RunesData` struct
802/// containing all rune data if exactly 9 runes are found.
803///
804/// # Parameters:
805/// - `first_table`: A `select::node::Node` representing the primary rune table.
806/// - `second_table`: A `select::node::Node` representing the secondary rune table.
807///
808/// # Returns:
809/// - `RunesData`: Returns a `RunesData` struct containing the extracted rune information. If the number of
810/// runes found is not exactly 9, it returns a `RunesData` struct with empty strings for all fields.
811///
812/// # Example:
813/// This function is typically called to process rune tables extracted from a web page:
814///
815/// ```rust
816/// let runes_data = extract_runes(primary_table, secondary_table);
817/// println!("{:?}", runes_data);
818/// ```
819///
820/// # Notes:
821/// - The function first collects all `img` tags from both the primary and secondary rune tables.
822/// - It filters out images that have a parent `div` with `opacity: 0.2` and skips any image without a valid `alt` attribute.
823/// - The `clean_alt_text` function is applied to clean up the `alt` text before it is added to the final rune list.
824/// - The function expects exactly 9 runes: 4 primary runes, 2 secondary runes, and 3 tertiary runes. If this condition is not met, an empty `RunesData` struct is returned.
825fn extract_runes(first_table: select::node::Node, second_table: select::node::Node) -> RunesData {
826 let images = first_table
827 .find(Name("img"))
828 .chain(second_table.find(Name("img")))
829 .filter_map(|img| {
830 if let Some(parent_div) = img.parent() {
831 if parent_div.attr("style") != Some("opacity: 0.2;") {
832 if let Some(alt) = img.attr("alt") {
833 if !alt.trim().is_empty() {
834 return Some(clean_alt_text(alt));
835 }
836 }
837 }
838 }
839 None
840 })
841 .collect::<Vec<String>>();
842
843 if images.len() == 9 {
844 RunesData {
845 parent_primary_rune: images[0].clone(),
846 child_primary_rune_1: images[1].clone(),
847 child_primary_rune_2: images[2].clone(),
848 child_primary_rune_3: images[3].clone(),
849 child_secondary_rune_1: images[4].clone(),
850 child_secondary_rune_2: images[5].clone(),
851 tertiary_rune_1: images[6].clone(),
852 tertiary_rune_2: images[7].clone(),
853 tertiary_rune_3: images[8].clone(),
854 }
855 } else {
856 RunesData {
857 parent_primary_rune: String::new(),
858 child_primary_rune_1: String::new(),
859 child_primary_rune_2: String::new(),
860 child_primary_rune_3: String::new(),
861 child_secondary_rune_1: String::new(),
862 child_secondary_rune_2: String::new(),
863 tertiary_rune_1: String::new(),
864 tertiary_rune_2: String::new(),
865 tertiary_rune_3: String::new(),
866 }
867 }
868}
869
870/// ⚙️ **Function**: Extracts the core build items from an HTML `iconsRow` div.
871///
872/// This function processes a div containing the core build items for a champion, typically found
873/// in the `iconsRow` HTML element. It extracts the `alt` attributes of the item images, cleans them,
874/// and returns the first, second, and third items in the build as a `CoreBuildData` struct.
875///
876/// # Parameters:
877/// - `icons_row`: A `select::node::Node` representing the `iconsRow` div that contains the item icons for the core build.
878///
879/// # Returns:
880/// - `CoreBuildData`: Returns a `CoreBuildData` struct containing the names of the first, second, and third core build items.
881///
882/// # Example:
883/// This function is typically called to process the core build for a specific champion:
884///
885/// ```rust
886/// let core_build = extract_core_build(icons_row);
887/// println!("{:?}", core_build);
888/// ```
889///
890/// # Notes:
891/// - The function collects all `img` tags within the `iconsRow` div and extracts the `alt` attributes, which contain the names of the items.
892/// - The `clean_alt_text` function is used to clean the `alt` text by removing unnecessary characters and formatting it.
893/// - The function assumes that the images vector contains at least four elements, where the first image is ignored and the second, third, and fourth images represent the core build items.
894/// - If the `iconsRow` div does not contain enough images, this could result in an `index out of bounds` error, so ensure the data is well-formed before calling the function.
895fn extract_core_build(icons_row: select::node::Node) -> CoreBuildData {
896 let images = icons_row
897 .find(Name("img"))
898 .filter_map(|img| img.attr("alt"))
899 .map(clean_alt_text)
900 .collect::<Vec<String>>();
901 CoreBuildData {
902 first: images[1].clone(),
903 second: images[2].clone(),
904 third: images[3].clone(),
905 }
906}
907
908/// ⚙️ **Function**: Cleans the alt text for an item or rune and applies special formatting.
909///
910/// This function processes the `alt` attribute text extracted from HTML elements, removes unwanted characters,
911/// and applies special rules. If the `alt` text contains certain characters (parentheses, hyphens, and plus signs),
912/// it returns "HealthScale". Otherwise, it cleans the text by removing parentheses, numbers, and specific symbols.
913///
914/// # Parameters:
915/// - `alt`: A string slice representing the `alt` text from an HTML `img` tag that needs to be cleaned.
916///
917/// # Returns:
918/// - `String`: Returns a cleaned version of the `alt` text. If the `alt` text matches specific patterns (parentheses, hyphen, and plus signs),
919/// it returns "HealthScale". Otherwise, it returns the cleaned text with unwanted characters removed.
920///
921/// # Example:
922/// This function is typically called to clean the text from the `alt` attributes of item or rune images:
923///
924/// ```rust
925/// let clean_text = clean_alt_text("Health (100) + 10% - 5%");
926/// println!("{}", clean_text); // Output: "HealthScale"
927///
928/// let clean_text = clean_alt_text("Sunfire Aegis");
929/// println!("{}", clean_text); // Output: "SunfireAegis"
930/// ```
931///
932/// # Notes:
933/// - If the `alt` text contains parentheses `()`, a hyphen `-`, and a plus sign `+`, the function returns "HealthScale".
934/// - It uses regular expressions to remove unwanted characters such as parentheses, numbers, percentage symbols, commas, and others.
935/// - Spaces are also removed in the final output.
936fn clean_alt_text(alt: &str) -> String {
937 if alt.contains('(') && alt.contains(')') && alt.contains('-') && alt.contains('+') {
938 return "HealthScale".to_string();
939 }
940 let re_parentheses = Regex::new(r"\(.*?\)").unwrap();
941 let cleaned_alt = re_parentheses.replace_all(alt, "");
942 let re_unwanted = Regex::new(r"[+:1234567890%-',]").unwrap();
943 let cleaned_alt = re_unwanted.replace_all(&cleaned_alt, "").trim().to_string();
944
945 cleaned_alt.replace(" ", "")
946}
947
948#[derive(Debug, Clone, Default)]
949pub struct ChampionInfo {
950 pub champion_name: String,
951 pub popularity_winrate: String,
952 pub popularity_played_percentage: String,
953 pub ban_rate: String,
954 pub roles: Vec<String>,
955 pub champion_link: String,
956}
957
958/// Extrait le tableau JSON associé à `key` (ex: "rankings") même s'il est imbriqué/multiligne,
959/// puis retourne une liste de `ChampionInfo`.
960/// Si un champion apparaît plusieurs fois, on fusionne ses rôles (sans doublons).
961pub fn extract_info_from_json_arr(s: &str, key: &str) -> Vec<ChampionInfo> {
962 let mut out: Vec<ChampionInfo> = Vec::new();
963 let mut index_by_name: HashMap<String, usize> = HashMap::new();
964
965 let bytes = s.as_bytes();
966 let needle = format!(r#""{}""#, key);
967
968 // 1) trouver "key"
969 let key_pos = match s.find(&needle) {
970 Some(p) => p,
971 None => return out, // clé introuvable -> liste vide
972 };
973
974 // 2) trouver le ':' qui suit
975 let after_key = &s[key_pos + needle.len()..];
976 let colon_rel = match after_key.find(':') {
977 Some(c) => c,
978 None => return out,
979 };
980 let mut i = key_pos + needle.len() + colon_rel + 1;
981
982 // 3) avancer jusqu'au premier '['
983 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
984 i += 1;
985 }
986 if i >= bytes.len() || bytes[i] != b'[' {
987 if let Some(next_bracket) = bytes[i..].iter().position(|&b| b == b'[') {
988 i += next_bracket;
989 } else {
990 return out; // pas de tableau après la clé
991 }
992 }
993
994 // 4) parcourir en comptant les crochets, en ignorant les chaînes
995 let mut depth: i32 = 0;
996 let mut in_string = false;
997 let mut escape = false;
998
999 let mut j = i;
1000 while j < bytes.len() {
1001 let b = bytes[j];
1002
1003 if in_string {
1004 if escape {
1005 escape = false;
1006 } else if b == b'\\' {
1007 escape = true;
1008 } else if b == b'"' {
1009 in_string = false;
1010 }
1011 } else {
1012 match b {
1013 b'"' => in_string = true,
1014 b'[' => depth += 1,
1015 b']' => {
1016 depth -= 1;
1017 if depth == 0 {
1018 // On a trouvé la fin du tableau
1019 let slice = &s[i..=j];
1020
1021 // Parse le tableau JSON
1022 if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(slice) {
1023 for item in items {
1024 if let Value::Object(obj) = item {
1025 let champion_name = obj
1026 .get("championName")
1027 .and_then(|v| v.as_str())
1028 .unwrap_or("<unknown>")
1029 .to_string();
1030
1031 // popularity.winrate peut être "winrate" ou "winRate", nombre ou string
1032 let popularity_winrate = obj
1033 .get("popularity")
1034 .and_then(|p| p.get("winrate").or_else(|| p.get("winRate")))
1035 .and_then(|v| v.as_f64())
1036 .map(|f| format!("{:.4}", f))
1037 .unwrap_or_default();
1038
1039 let popularity_played_percentage = obj
1040 .get("popularity")
1041 .and_then(|p| p.get("playedPercentage"))
1042 .and_then(|v| v.as_f64())
1043 .map(|f| format!("{:.4}", f))
1044 .unwrap_or_default();
1045
1046 let ban_rate = obj
1047 .get("banRate")
1048 .and_then(|v| v.as_f64())
1049 .map(|f| format!("{:.4}", f))
1050 .unwrap_or_default();
1051
1052 let role_title = obj
1053 .get("role")
1054 .and_then(|r| r.get("title"))
1055 .and_then(|v| v.as_str())
1056 .unwrap_or_default()
1057 .to_string();
1058
1059 let champion_link = obj
1060 .get("championLink")
1061 .and_then(|v| v.as_str())
1062 .map(|s| {
1063 // Supprime le préfixe "/champions/builds/" s'il existe
1064 s.strip_prefix("/champions/builds/")
1065 .unwrap_or(s)
1066 .to_string()
1067 })
1068 .unwrap_or_default();
1069
1070 // Fusion dans la liste : si champion déjà présent, merge des rôles
1071 if let Some(&idx) = index_by_name.get(&champion_name) {
1072 // mettre à jour les champs "non rôles" si vides (on ne sait pas s'il faut écraser)
1073 if out[idx].popularity_winrate.is_empty()
1074 && !popularity_winrate.is_empty()
1075 {
1076 out[idx].popularity_winrate =
1077 popularity_winrate.clone();
1078 }
1079 if out[idx].popularity_played_percentage.is_empty()
1080 && !popularity_played_percentage.is_empty()
1081 {
1082 out[idx].popularity_played_percentage =
1083 popularity_played_percentage.clone();
1084 }
1085 if out[idx].ban_rate.is_empty() && !ban_rate.is_empty() {
1086 out[idx].ban_rate = ban_rate.clone();
1087 }
1088 if out[idx].champion_link.is_empty()
1089 && !champion_link.is_empty()
1090 {
1091 out[idx].champion_link = champion_link.clone();
1092 }
1093
1094 // merge du rôle si différent et non vide
1095 if !role_title.is_empty()
1096 && !out[idx].roles.iter().any(|r| r == &role_title)
1097 {
1098 out[idx].roles.push(role_title);
1099 }
1100 } else {
1101 let mut roles = Vec::new();
1102 if !role_title.is_empty() {
1103 roles.push(role_title);
1104 }
1105
1106 let info = ChampionInfo {
1107 champion_name: champion_name.clone(),
1108 popularity_winrate,
1109 popularity_played_percentage,
1110 ban_rate,
1111 roles,
1112 champion_link,
1113 };
1114 out.push(info);
1115 index_by_name.insert(champion_name, out.len() - 1);
1116 }
1117 }
1118 }
1119 }
1120
1121 break; // on a traité le tableau; on peut sortir
1122 }
1123 }
1124 _ => {}
1125 }
1126 }
1127 j += 1;
1128 }
1129
1130 out
1131}