diff --git a/Cargo.lock b/Cargo.lock index 401dc69..bc5a124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "time", + "time 0.3.22", "uuid", ] @@ -213,8 +213,11 @@ checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "time 0.1.45", + "wasm-bindgen", "winapi", ] @@ -612,7 +615,7 @@ checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -1075,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -1180,8 +1183,9 @@ dependencies = [ [[package]] name = "obsessed-yanqing" -version = "1.2.0" +version = "1.3.1" dependencies = [ + "chrono", "levenshtein", "mongodb", "poise", @@ -1863,7 +1867,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "time", + "time 0.3.22", "tokio", "tracing", "typemap_rev", @@ -2075,6 +2079,17 @@ dependencies = [ "syn 2.0.27", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.22" @@ -2412,6 +2427,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 5034c78..b5bf7fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsessed-yanqing" -version = "1.3.0" +version = "1.3.1" edition = "2021" authors = ["Evann Regnault"] license = "MIT" @@ -19,3 +19,4 @@ select = "0.6.0" regex = "1.9.1" rand = "0.8.5" mongodb = "2.6.0" +chrono = "0.4.26" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4b9002d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Evann REGNAULT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2619553 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Obsessed Yanqing + +This is a bot whose main purpose is to give as much infomrations as possible about Honkai Star Rail. + +> ⚠️ Disclaimer : All Data is fetched from [Prydwen.gg](https://prydwen.gg) + +## Features + +### Event messages +The command `/create_event_message ` creates, as its name indicate, an event message in the channel of your +choice.
This message will contain: + - All the currently running banners + - The currently going events + - The upcoming banners and events + - The currently known redeemable codes + +> In order to update this message every hour, three information are stored in a database : Guild id, Channel id, Message id. +> +> ❗ Those are the only information stored by the bot ! + + +### Character Data + +By using the command `/character `, you get access to loads of data about the selected character. + +Such as its rarity, role, description, pros and cons, its different builds, and the recommended teams. + +All the navigation is done by click and touch on the buttons bellow the message. + +If a character has multiple builds/gears, you need to click on th gear button again to navigate to the next one. + +## Setup + +If you want to setup this bot by yourself, a docker image is available on [my docker registry](https://registry.evannregnault.dev/#!/taglist/obsessed-yanqing). + +You'll also need a mongoDB server alongside it. + +### ENV +```env +TOKEN : The discord bot's token +MONGO_HOST : The hostname of the mongodb instance +MONGO_PORT : The port of the mongodb instance +``` + +## APIs / Libraries + +- [Prydwen.gg](https://www.prydwen.gg/star-rail/) +- [Serenity](https://github.com/serenity-rs/serenity) +- [Poise](https://github.com/serenity-rs/poise) \ No newline at end of file diff --git a/src/commands/character.rs b/src/commands/character.rs index e33d5d4..4e33861 100644 --- a/src/commands/character.rs +++ b/src/commands/character.rs @@ -202,7 +202,7 @@ fn create_character_tabs_button<'a>(f: &'a mut CreateComponents, char: &Characte gear_button.disabled(false); match current_tab { CharacterTab::Gear(n) => { - gear_button.label(format!("Gear {}/{}", n+1, char.build_data.as_ref().expect("").len())); + gear_button.label(format!("Gear {}/{}", n+1, char.build_data.as_ref().expect("Cannot find gear").len())); } _ => {gear_button.label("Gear");} } diff --git a/src/commands/events.rs b/src/commands/events.rs index edb6aaa..12cddd6 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -1,6 +1,8 @@ use std::cmp::Ordering; use std::i32; use std::str::Split; +use std::time::{SystemTime, UNIX_EPOCH}; +use chrono::{NaiveDateTime}; use regex::{Regex}; use select::document::Document; use select::node::Node; @@ -32,11 +34,17 @@ struct BannerData { four_stars: Vec } +#[derive(Clone)] +struct EventTime { + start: Option, + end: Option +} + #[derive(Clone)] struct Event { name: String, description: Option<(String, String)>, - time: Option, + time: EventTime, image: Option, banner_data: Option, color: Option, @@ -49,31 +57,62 @@ struct Code { } fn get_current_events_from_document(doc: &str) -> Vec { + let events = get_all_events(doc); + + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time no longer works").as_secs() as i64; + events.into_iter().filter(|p| match p.time.start { + None => true, + Some(start) => { + start <= current_time && match p.time.end { + None => true, + Some(end) => current_time < end + } + } + }).collect() +} + +fn get_upcoming_events_from_document(doc: &str) -> Vec { + let events = get_all_events(doc); + + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time no longer works").as_secs() as i64; + events.into_iter().filter(|p| match p.time.start { + None => false, + Some(start) => { + start > current_time && match p.time.end { + None => true, + Some(end) => current_time < end + } + } + }).collect() +} + +fn get_all_events(doc: &str) -> Vec { let document = Document::from(doc); let nodes = document.find(Class("event-tracker")).collect::>(); - let event_nodes = nodes.get(0); - - let mut events : Vec = vec![]; - - parse_events(&doc, event_nodes, &mut events); + let mut events: Vec = vec![]; + let current_event_nodes = nodes.get(0); + parse_events(&doc, current_event_nodes, &mut events); + let upcoming_event_nodes = nodes.get(1); + parse_events(&doc, upcoming_event_nodes, &mut events); events } + fn parse_events(doc: &&str, event_nodes: Option<&Node>, events: &mut Vec) { if let Some(x) = event_nodes { for event in x.find(Class("accordion-item")) { - let mut attributes = event.attr("class").expect("").split(' '); + let mut attributes = event.attr("class").expect("Cant find class attribute").split(' '); if attributes.clone().count() != 2 { continue; } let name = event.find(Class("event-name")).next().expect("Cannot find name").text(); - let (image, color) = get_image_color(&doc, &mut attributes); + let (image, color) = get_image_color(doc, &mut attributes); let description = get_description(event); - let time = event.find(Class("time")).next().map(|x| x.text()); + let time = get_time(event); let event_type = get_event_type(event, &description); @@ -84,16 +123,30 @@ fn parse_events(doc: &&str, event_nodes: Option<&Node>, events: &mut Vec) }; } -fn get_upcoming_events_from_document(doc: &str) -> Vec { - let document = Document::from(doc); - let nodes = document.find(Class("event-tracker")).collect::>(); - let event_nodes = nodes.get(1); - let mut events : Vec = vec![]; - parse_events(&doc, event_nodes, &mut events); - events +fn get_date(date_string: &str) -> Option { + let date_regex = Regex::new(r"(?m)[0-9]{4}/(?:1[0-9]|0[1-9])/(?:0[1-9]|[1-2][0-9]|3[0-1]) (?:(?:[01][0-9]|2[0-3])|[0-9]):[0-5][0-9]").expect("Cannot compile time regex"); + date_regex.captures(date_string).map(|x| x.get(0).expect("No captures found").as_str().to_string()) } +fn get_time(event: Node) -> EventTime { + event.find(Class("duration")).next().map(|x| { + let text = x.text(); + let dates = Regex::new(r" [-–] ").expect("Can't create split time regex").split(text.as_str()).collect::>(); + let start_date_text = dates.first().expect("Cannot get start date string").to_owned(); + let end_date_text = dates.get(1).expect("Cannot get end date string").to_owned(); + let start = get_date(start_date_text).map(|x| { + NaiveDateTime::parse_from_str(x.as_str(), "%Y/%m/%d %H:%M").expect("Date").timestamp() + }); + let end = get_date(end_date_text).map(|x| { + NaiveDateTime::parse_from_str(x.as_str(), "%Y/%m/%d %H:%M").expect("Date").timestamp() + }); + + EventTime {start, end} + }).expect("No time found") +} + + fn get_codes_from_document(doc: &str) -> Option> { let document = Document::from(doc); document.find(Class("codes")).next().map( |codes | { @@ -125,7 +178,7 @@ fn get_description(event: Node) -> Option<(String, String)> { let description = event.find(Class("description")).next().map(|x| { let text = x.text(); let desc = text.split(": ").collect::>(); - (desc.first().expect("").to_string(), desc.get(1).expect("").to_string()) + (desc.first().expect("Cannot get description title").to_string(), desc.get(1).expect("Cannot get description text").to_string()) }); description } @@ -163,7 +216,7 @@ fn get_event_type(event: Node, description: &Option<(String, String)>) -> EventT fn get_banner_data(event: Node) -> Option { let five_stars = event.find(Class("rarity-5").descendant(Name("picture")).descendant(Name("img"))).next().map(|x| x.attr("alt").expect("No alt on five star image").to_string()); let four_stars = event.find(Class("rarity-4")).take(3).map(|four_stars_node| { - four_stars_node.find(Name("picture").descendant(Name("img"))).next().map(|x| x.attr("alt").expect("No alt on four star image").to_string()).expect("") + four_stars_node.find(Name("picture").descendant(Name("img"))).next().map(|x| x.attr("alt").expect("No alt on four star image").to_string()).expect("Cannot get four stars names") }).collect::>(); match five_stars { @@ -176,7 +229,7 @@ fn get_banner_data(event: Node) -> Option { } fn create_current_events_embeds(doc : &str) -> Vec { - let mut events = get_current_events_from_document(doc); + let mut events = get_current_events_from_document(doc).into_iter().take(8).collect::>(); events.sort_by(sort_events()); @@ -193,8 +246,8 @@ fn create_current_events_embeds(doc : &str) -> Vec { embed = embed.color(color); } - if let Some(time) = event.time { - embed = embed.description(format!("Time remaining : {}", time)); + if let Some(time) = event.time.end { + embed = embed.description(format!("Ends ", time)); } if let Some(description) = event.description { @@ -217,17 +270,16 @@ fn create_upcoming_embed(doc : &str) -> Option { events.sort_by(sort_events()); if events.is_empty() {return None;} - let banner_events : Vec = events.to_vec().into_iter().filter(|p| matches!(p.event_type, EventType::ConeBanner | EventType::CharacterBanner)).collect(); - - let other_events: Vec = events.iter().cloned().filter(|p| !matches!(p.event_type, EventType::ConeBanner | EventType::CharacterBanner)).collect(); + let banner_events : Vec = events.iter().cloned().filter(|p| matches!(p.event_type, EventType::ConeBanner | EventType::CharacterBanner)).collect(); + let other_events: Vec = events.iter().cloned().filter(|p| !matches!(p.event_type, EventType::ConeBanner | EventType::CharacterBanner)).rev().collect(); Some(CreateEmbed::default() .title("Upcoming Events") .field("Banners", banner_events.iter().map(|banner| { - format!("{} - **{}** : Starts in {}", banner.name, banner.banner_data.as_ref().expect("").five_stars, banner.time.as_ref().expect("")) + format!("{} - **{}** : Starts ", banner.name, banner.banner_data.as_ref().expect("No Banner Data on Banner").five_stars, banner.time.start.expect("No start time for Upcomming Event")) }).collect::>().join("\n"),false) .field("Events", other_events.iter().map(|event| { - format!("{} : Starts in {}", event.name, event.time.as_ref().expect("")) + format!("{} : Starts ", event.name, event.time.start.expect("No start time for Upcomming Event")) }).collect::>().join("\n"),false) .color(Color::from_rgb(rand::random(), rand::random(), rand::random())) .footer(|f| f.text("Data from https://www.prydwen.gg")) @@ -245,6 +297,7 @@ fn create_codes_embed(doc : &str) -> Option { false => format!("- {}", code.code) } }).collect::>().join("\n")) + .url("https://hsr.hoyoverse.com/gift") .color(Color::from_rgb(rand::random(), rand::random(), rand::random())) .footer(|f| f.text("Data from https://www.prydwen.gg")) .to_owned()) @@ -253,6 +306,7 @@ fn create_codes_embed(doc : &str) -> Option { pub async fn create_event_embeds() -> Vec { let doc = get_main_prydwen().await; + let mut embeds: Vec = vec![]; embeds.append(create_current_events_embeds(&doc).as_mut()); if let Some(embed) = create_upcoming_embed(&doc) { @@ -281,7 +335,17 @@ fn sort_events() -> fn(&Event, &Event) -> Ordering { (EventType::Other, EventType::ConeBanner) => Ordering::Greater, (EventType::ConeBanner, EventType::CharacterBanner) => Ordering::Greater, - (_, _) => { Ordering::Equal } + (_, _) => { + match (a.time.end, b.time.end) { + (Some(a_end), Some(b_end)) => { + match a_end > b_end { + true => Ordering::Greater, + false => Ordering::Less + } + } + _ => Ordering::Equal + } + } } } } @@ -294,7 +358,7 @@ pub async fn create_event_message( #[description = "Create Event Tab"] channel: Channel ) -> Result<(), Error> { - let message = get_discord_status_message(ctx.guild().expect("").id.0).await; + let message = get_discord_status_message(ctx.guild().expect("Message not sent in guild").id.0).await; if let Some(e) = message { let rm = ChannelId::from(e.channel_id as u64) .message(&ctx.http(), MessageId::from(e.message_id as u64)) diff --git a/src/main.rs b/src/main.rs index cc24cad..755da38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,13 +3,17 @@ mod data; mod utils; mod mongo; +use std::any::Any; +use std::fmt::Display; +use std::hash::Hash; use std::time::Duration; +use poise::FrameworkError; use poise::serenity_prelude::GatewayIntents; use serenity::client::Context; use serenity::model::id::ChannelId; use serenity::model::prelude::Activity; use crate::commands::events::create_event_embeds; -use crate::data::{Data}; +use crate::data::{Data, Error}; use crate::mongo::core::get_all_status_messages; fn update_daily(ctx: Context) {