diff --git a/src/general.rs b/src/general.rs new file mode 100644 index 0000000..4154e34 --- /dev/null +++ b/src/general.rs @@ -0,0 +1,54 @@ +use log::{info, warn}; +use serenity::client::Context; +use serenity::framework::standard::CommandResult; +use serenity::model::channel::Message; +use serenity::utils::Colour; +use std::time::{SystemTime, UNIX_EPOCH}; + +const BOT_SOURCE: &str = "https://github.com/Paoda/arona"; +const GACHA_SOURCE: &str = "https://github.com/Paoda/bluearch-recruitment"; +const IMG_SOURCE: &str = "https://thearchive.gg"; +pub const BLUE_ARCHIVE_BLUE: Colour = Colour::from_rgb(0, 215, 251); + +pub async fn ping(ctx: &Context, msg: &Message) -> CommandResult { + let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); + info!("Ping requested from {}", author_name); + + let now = SystemTime::now(); + + match now.duration_since(UNIX_EPOCH) { + Ok(now_timestamp) => { + let diff = now_timestamp.as_millis() - msg.timestamp.timestamp_millis() as u128; + info!("It took {}ms to receive {}'s ping", diff, author_name); + + msg.reply(ctx, format!("Pong! (Response: {}ms)", diff)) + .await?; + } + Err(_) => { + warn!("Failed to calculate UNIX Timestamp"); + msg.reply(ctx, "Pong! (Response: ??ms)").await?; + } + } + + Ok(()) +} + +pub async fn source(ctx: &Context, msg: &Message) -> CommandResult { + let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); + info!("{} requested bot / gacha / image sources", author_name); + let channel = msg.channel_id; + + channel + .send_message(ctx, |m| { + m.embed(|embed| { + embed + .field("Bot Source", BOT_SOURCE, false) + .field("Gacha Source", GACHA_SOURCE, false) + .field("Image Source", IMG_SOURCE, false) + .colour(BLUE_ARCHIVE_BLUE) + }) + }) + .await?; + + Ok(()) +} diff --git a/src/image.rs b/src/image.rs new file mode 100644 index 0000000..f0db815 --- /dev/null +++ b/src/image.rs @@ -0,0 +1,85 @@ +use image::imageops::FilterType; +use image::io::Reader as ImageReader; +use image::{Rgba, RgbaImage}; +use lazy_static::lazy_static; +use log::{error, info, warn}; +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::Mutex; + +type Cache = Mutex>; +lazy_static! { + static ref CACHE: Cache = Mutex::new(HashMap::new()); +} + +pub async fn get_image_from_url(url: &str, width: u32, height: u32) -> RgbaImage { + if let Some(img) = check_cache(url) { + info!("Cache Hit for {}", url); + return img; + } + + info!("Downloading {}", url); + match reqwest::get(url).await { + Ok(resp) => match resp.bytes().await { + Ok(bytes) => match ImageReader::new(Cursor::new(bytes)).with_guessed_format() { + Ok(img_reader) => match img_reader.decode() { + Ok(dynamic_img) => { + info!("Successfully decoded image from {}", url); + let img = image::imageops::resize( + &dynamic_img, + width, + height, + FilterType::Nearest, + ); + + add_to_cache(url, &img); + img + } + Err(err) => { + warn!("Decoding Error: {}", err); + generate_default_img(width, height) + } + }, + Err(err) => { + error!("Unexpected IO Error Occurred: {}", err); + // We can recover here, but maybe it's worth panicking here? + generate_default_img(width, height) + } + }, + + Err(err) => { + warn!("Response Parse failed: {:}?", err); + generate_default_img(width, height) + } + }, + Err(err) => { + warn!("Download failed: {:?}", err); + generate_default_img(width, height) + } + } +} + +fn check_cache(url: &str) -> Option { + if let Ok(lock) = CACHE.lock() { + lock.get(url).cloned() + } else { + None + } +} + +fn add_to_cache(url: &str, img: &RgbaImage) { + if let Ok(ref mut lock) = CACHE.lock() { + lock.insert(url.to_string(), img.clone()); + } +} + +fn generate_default_img(width: u32, height: u32) -> RgbaImage { + let mut img = RgbaImage::new(width, height); + for x in 0..height { + for y in 0..width { + img.put_pixel(x, y, Rgba([160, 32, 240, 1])); + } + } + + img +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3f7f0cc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod general; +pub mod image; +pub mod recruitment; diff --git a/src/main.rs b/src/main.rs index c075b7f..e4208e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,5 @@ -use bluearch_recruitment::banner::{Banner, BannerBuilder}; -use bluearch_recruitment::gacha::{GachaBuilder, Rarity, Recruitment as RecruitmentTrait}; -use bluearch_recruitment::i18n::Language; -use bluearch_recruitment::student::Student; use dotenv::dotenv; -use image::codecs::jpeg::JpegEncoder; -use image::imageops::FilterType; -use image::io::Reader as ImageReader; -use image::{ColorType, ImageEncoder, Rgba, RgbaImage}; -use lazy_static::lazy_static; -use log::{debug, error, info, warn}; +use log::{debug, error, info}; use serenity::async_trait; use serenity::client::{Client, Context, EventHandler}; use serenity::framework::standard::macros::{command, group, help}; @@ -17,12 +8,9 @@ use serenity::framework::standard::{ }; use serenity::model::channel::Message; use serenity::model::id::UserId; -use serenity::utils::Colour; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::env; -use std::io::Cursor; -use std::sync::Mutex; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; + #[group] #[commands(ping, source)] struct General; @@ -36,22 +24,6 @@ struct Handler; #[async_trait] impl EventHandler for Handler {} -const STUDENTS_JSON: &str = include_str!("../data/students.json"); -const CDN_URL: &str = "https://rerollcdn.com/BlueArchive"; -const BOT_SOURCE: &str = "https://git.paoda.moe/paoda/arona"; -const GACHA_SOURCE: &str = "https://github.com/Paoda/bluearch-recruitment"; -const IMG_SOURCE: &str = "https://thearchive.gg"; -const BANNER_IMG_URL: &str = "https://static.wikia.nocookie.net/blue-archive/images/e/e0/Gacha_Banner_01.png/revision/latest/"; -const BLUE_ARCHIVE_BLUE: Colour = Colour::from_rgb(0, 215, 251); - -type Cache = Mutex>; - -lazy_static! { - static ref STUDENTS: Vec = serde_json::from_str(STUDENTS_JSON).unwrap(); - static ref BANNER: Banner = create_banner(); - static ref CACHE: Cache = Mutex::new(HashMap::new()); -} - #[tokio::main] async fn main() { dotenv().ok(); @@ -100,282 +72,30 @@ async fn main() { #[command] #[aliases(response)] async fn ping(ctx: &Context, msg: &Message) -> CommandResult { - let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); - info!("Ping requested from {}", author_name); - - let now = SystemTime::now(); - - match now.duration_since(UNIX_EPOCH) { - Ok(now_timestamp) => { - let diff = now_timestamp.as_millis() - msg.timestamp.timestamp_millis() as u128; - info!("It took {}ms to receive {}'s ping", diff, author_name); - - msg.reply(ctx, format!("Pong! (Response: {}ms)", diff)) - .await?; - } - Err(_) => { - warn!("Failed to calculate UNIX Timestamp"); - msg.reply(ctx, "Pong! (Response: ??ms)").await?; - } - } - - Ok(()) + arona::general::ping(ctx, msg).await } #[command] #[aliases(pull)] async fn roll(ctx: &Context, msg: &Message) -> CommandResult { - let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); - info!("{} requested a single roll", author_name); - - let channel = msg.channel_id; - let student = BANNER.roll(); - - let eng_name = student.name.get(Language::English).unwrap(); - let url_name = if eng_name == "Junko" { - "Zunko" - } else { - &eng_name - }; - - let img_url = format!("{}/Characters/{}.png", CDN_URL, url_name); - let title_url = format!("https://www.thearchive.gg/characters/{}", url_name); - let icon_url = format!("{}/Icons/icon-brand.png", CDN_URL); - let rarity_colour = get_rarity_colour(student.rarity); - - let rarity_str = match student.rarity { - Rarity::One => ":star:", - Rarity::Two => ":star::star:", - Rarity::Three => ":star::star::star:", - }; - - channel - .send_message(ctx, |m| { - m.embed(|embed| { - embed - .image(img_url) - .title(format!("{}", student.name)) - .description(format!("{}\t{}", eng_name, rarity_str)) - .url(title_url) - .footer(|footer| { - footer - .icon_url(icon_url) - .text("Image Source: https://thearchive.gg") - }) - .colour(rarity_colour) - }) - }) - .await?; - - Ok(()) + arona::recruitment::roll(ctx, msg).await } #[command] #[aliases(tenroll)] async fn roll10(ctx: &Context, msg: &Message) -> CommandResult { - let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); - info!("{} requested a ten roll", author_name); - let channel = msg.channel_id; - - let typing = channel.start_typing(&ctx.http)?; - - const RESIZE_WIDTH: u32 = 202; // OG: 404 (2020-02-11) - const RESIZE_HEIGHT: u32 = 228; // OG: 456 (2020-02-11) - const IMG_WIDTH: u32 = RESIZE_WIDTH * 5; - const IMG_HEIGHT: u32 = RESIZE_HEIGHT * 2; - - let mut collage = RgbaImage::new(IMG_WIDTH, IMG_HEIGHT); - let mut images: Vec = Vec::with_capacity(10); - - let students = BANNER.roll10(); - let mut max_rarity = Rarity::One; - - let start = Instant::now(); - for student in students.iter() { - let eng_name = student.name.get(Language::English).unwrap(); - let url_name = if eng_name == "Junko" { - "Zunko" - } else { - &eng_name - }; - - max_rarity = max_rarity.max(student.rarity); - - let img_url = format!("{}/Characters/{}.png", CDN_URL, url_name); - let image = get_image_from_url(&img_url, RESIZE_WIDTH, RESIZE_HEIGHT).await; - - images.push(image); - } - let elapsed_ms = (Instant::now() - start).as_millis(); - info!("10-roll, DL, and resize took {}ms", elapsed_ms); - - let start = Instant::now(); - for x in (0..IMG_WIDTH).step_by(RESIZE_WIDTH as usize) { - let index: usize = (((IMG_WIDTH - x) / RESIZE_WIDTH) - 1) as usize; - - // Top Image - image::imageops::overlay(&mut collage, &images[index], x, 0); - - // Bottom Image - image::imageops::overlay(&mut collage, &images[index + 5], x, RESIZE_HEIGHT); - } - let elapsed_ms = (Instant::now() - start).as_millis(); - info!("Collage Build took {}ms", elapsed_ms); - - let mut jpeg = Vec::new(); - let encoder = JpegEncoder::new(&mut jpeg); - - let write_result = encoder.write_image(&collage, IMG_WIDTH, IMG_HEIGHT, ColorType::Rgba8); - - if let Err(err) = write_result { - error!("Failed to Encode JPEG: {:?}", err); - msg.reply( - ctx, - "アロナ failed to perform your 10-roll. Please try again", - ) - .await?; - return Ok(()); - } - - let files = vec![(jpeg.as_slice(), "result.jpeg")]; - let _ = typing.stop(); - channel - .send_files(ctx, files, |m| { - m.embed(|embed| { - embed - .title(format!("{} 10-roll", BANNER.name)) - .description(BANNER.name.get(Language::English).unwrap()) - .attachment("result.jpeg") - .colour(get_rarity_colour(max_rarity)) - }) - }) - .await?; - Ok(()) -} - -async fn get_image_from_url(url: &str, width: u32, height: u32) -> RgbaImage { - if let Some(img) = check_cache(url) { - info!("Cache Hit for {}", url); - return img; - } - - info!("Downloading {}", url); - match reqwest::get(url).await { - Ok(resp) => match resp.bytes().await { - Ok(bytes) => match ImageReader::new(Cursor::new(bytes)).with_guessed_format() { - Ok(img_reader) => match img_reader.decode() { - Ok(dynamic_img) => { - info!("Successfully decoded image from {}", url); - let img = image::imageops::resize( - &dynamic_img, - width, - height, - FilterType::Nearest, - ); - - add_to_cache(url, &img); - img - } - Err(err) => { - warn!("Decoding Error: {}", err); - generate_default_img(width, height) - } - }, - Err(err) => { - error!("Unexpected IO Error Occurred: {}", err); - // We can recover here, but maybe it's worth panicking here? - generate_default_img(width, height) - } - }, - - Err(err) => { - warn!("Response Parse failed: {:}?", err); - generate_default_img(width, height) - } - }, - Err(err) => { - warn!("Download failed: {:?}", err); - generate_default_img(width, height) - } - } -} - -fn get_rarity_colour(rarity: Rarity) -> Colour { - match rarity { - Rarity::One => Colour::from_rgb(227, 234, 240), - Rarity::Two => Colour::from_rgb(255, 248, 124), - Rarity::Three => Colour::from_rgb(253, 198, 229), - } -} - -fn generate_default_img(width: u32, height: u32) -> RgbaImage { - let mut img = RgbaImage::new(width, height); - for x in 0..height { - for y in 0..width { - img.put_pixel(x, y, Rgba([160, 32, 240, 1])); - } - } - - img -} - -fn check_cache(url: &str) -> Option { - if let Ok(lock) = CACHE.lock() { - lock.get(url).cloned() - } else { - None - } -} - -fn add_to_cache(url: &str, img: &RgbaImage) { - if let Ok(ref mut lock) = CACHE.lock() { - lock.insert(url.to_string(), img.clone()); - } + arona::recruitment::roll10(ctx, msg).await } #[command] async fn banner(ctx: &Context, msg: &Message) -> CommandResult { - let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); - info!("{} requested banner information", author_name); - - let channel = msg.channel_id; - let banner_eng = BANNER.name.get(Language::English).unwrap(); - - channel - .send_message(ctx, |m| { - m.embed(|embed| { - embed - .image(BANNER_IMG_URL) - .title(BANNER.name.clone()) - .description(banner_eng) - .colour(BLUE_ARCHIVE_BLUE) - }) - }) - .await?; - - Ok(()) + arona::recruitment::banner(ctx, msg).await } #[command] #[aliases(github, code, dev)] async fn source(ctx: &Context, msg: &Message) -> CommandResult { - let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); - info!("{} requested bot / gacha / image sources", author_name); - let channel = msg.channel_id; - - channel - .send_message(ctx, |m| { - m.embed(|embed| { - embed - .field("Bot Source", BOT_SOURCE, false) - .field("Gacha Source", GACHA_SOURCE, false) - .field("Image Source", IMG_SOURCE, false) - .colour(BLUE_ARCHIVE_BLUE) - }) - }) - .await?; - - Ok(()) + arona::general::source(ctx, msg).await } #[help] @@ -392,30 +112,3 @@ async fn my_help( let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; Ok(()) } - -pub fn create_banner() -> Banner { - let pool: Vec = STUDENTS - .iter() - .filter(|student| student.name != "ノゾミ") - .cloned() - .collect(); - - let sparkable: Vec = pool - .iter() - .filter(|student| student.name == "ホシノ" || student.name == "シロコ") - .cloned() - .collect(); - - let gacha = GachaBuilder::new(79.0, 18.5, 2.5) - .with_pool(pool) - .with_priority(&sparkable, 0.7) - .finish() - .unwrap(); - - BannerBuilder::new("ピックアップ募集") - .with_gacha(&gacha) - .with_name_translation(Language::English, "Rate-Up Recruitment") - .with_sparkable_students(&sparkable) - .finish() - .unwrap() -} diff --git a/src/recruitment.rs b/src/recruitment.rs new file mode 100644 index 0000000..c0b6937 --- /dev/null +++ b/src/recruitment.rs @@ -0,0 +1,217 @@ +use crate::general::BLUE_ARCHIVE_BLUE; +use crate::image::get_image_from_url; +use bluearch_recruitment::banner::{Banner, BannerBuilder}; +use bluearch_recruitment::gacha::Recruitment as RecruitmentTrait; +use bluearch_recruitment::gacha::{GachaBuilder, Rarity}; +use bluearch_recruitment::i18n::Language; +use bluearch_recruitment::student::Student; +use image::jpeg::JpegEncoder; +use image::{ColorType, ImageEncoder, RgbaImage}; +use lazy_static::lazy_static; +use log::{error, info}; +use serenity::client::Context; +use serenity::framework::standard::CommandResult; +use serenity::model::channel::Message; +use serenity::utils::Colour; + +use std::time::Instant; + +const STUDENTS_JSON: &str = include_str!("../data/students.json"); +const CDN_URL: &str = "https://rerollcdn.com/BlueArchive"; +const BANNER_IMG_URL: &str = "https://static.wikia.nocookie.net/blue-archive/images/e/e0/Gacha_Banner_01.png/revision/latest/"; +const THUMB_WIDTH: u32 = 202; // OG: 404 (2020-02-11) from https://thearchive.gg +const THUMB_HEIGHT: u32 = 228; // OG: 456 (2020-02-11) from https://thearchive.gg + +lazy_static! { + static ref STUDENTS: Vec = serde_json::from_str(STUDENTS_JSON).unwrap(); + static ref BANNER: Banner = create_banner(); +} + +pub async fn roll(ctx: &Context, msg: &Message) -> CommandResult { + let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); + info!("{} requested a single roll", author_name); + + let channel = msg.channel_id; + let student = BANNER.roll(); + + let eng_name = student.name.get(Language::English).unwrap(); + let url_name = if eng_name == "Junko" { + "Zunko" + } else { + &eng_name + }; + + let img_url = format!("{}/Characters/{}.png", CDN_URL, url_name); + let title_url = format!("https://www.thearchive.gg/characters/{}", url_name); + let icon_url = format!("{}/Icons/icon-brand.png", CDN_URL); + let rarity_colour = get_rarity_colour(student.rarity); + + let rarity_str = match student.rarity { + Rarity::One => ":star:", + Rarity::Two => ":star::star:", + Rarity::Three => ":star::star::star:", + }; + + channel + .send_message(ctx, |m| { + m.embed(|embed| { + embed + .image(img_url) + .title(format!("{}", student.name)) + .description(format!("{}\t{}", eng_name, rarity_str)) + .url(title_url) + .footer(|footer| { + footer + .icon_url(icon_url) + .text("Image Source: https://thearchive.gg") + }) + .colour(rarity_colour) + }) + }) + .await?; + + Ok(()) +} + +pub async fn roll10(ctx: &Context, msg: &Message) -> CommandResult { + let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); + info!("{} requested a ten roll", author_name); + let channel = msg.channel_id; + + let typing = channel.start_typing(&ctx.http)?; + + const IMG_WIDTH: u32 = THUMB_WIDTH * 5; + const IMG_HEIGHT: u32 = THUMB_HEIGHT * 2; + + let mut collage = RgbaImage::new(IMG_WIDTH, IMG_HEIGHT); + let mut images: Vec = Vec::with_capacity(10); + + let students = BANNER.roll10(); + let mut max_rarity = Rarity::One; + + let start = Instant::now(); + for student in students.iter() { + let eng_name = student.name.get(Language::English).unwrap(); + let url_name = if eng_name == "Junko" { + "Zunko" + } else { + &eng_name + }; + + max_rarity = max_rarity.max(student.rarity); + + let img_url = format!("{}/Characters/{}.png", CDN_URL, url_name); + let image = get_image_from_url(&img_url, THUMB_WIDTH, THUMB_HEIGHT).await; + + images.push(image); + } + let elapsed_ms = (Instant::now() - start).as_millis(); + info!("10-roll, DL, and resize took {}ms", elapsed_ms); + + let start = Instant::now(); + for x in (0..IMG_WIDTH).step_by(THUMB_WIDTH as usize) { + let index: usize = (((IMG_WIDTH - x) / THUMB_WIDTH) - 1) as usize; + + // Top Image + image::imageops::overlay(&mut collage, &images[index], x, 0); + + // Bottom Image + image::imageops::overlay(&mut collage, &images[index + 5], x, THUMB_HEIGHT); + } + let elapsed_ms = (Instant::now() - start).as_millis(); + info!("Collage Build took {}ms", elapsed_ms); + + let mut jpeg = Vec::new(); + let encoder = JpegEncoder::new(&mut jpeg); + + let write_result = encoder.write_image(&collage, IMG_WIDTH, IMG_HEIGHT, ColorType::Rgba8); + + if let Err(err) = write_result { + error!("Failed to Encode JPEG: {:?}", err); + msg.reply( + ctx, + "アロナ failed to perform your 10-roll. Please try again", + ) + .await?; + return Ok(()); + } + + let icon_url = format!("{}/Icons/icon-brand.png", CDN_URL); + + let files = vec![(jpeg.as_slice(), "result.jpeg")]; + let _ = typing.stop(); + channel + .send_files(ctx, files, |m| { + m.embed(|embed| { + embed + .title(format!("{} 10-roll", BANNER.name)) + .description(BANNER.name.get(Language::English).unwrap()) + .attachment("result.jpeg") + .colour(get_rarity_colour(max_rarity)) + .footer(|footer| { + footer + .icon_url(icon_url) + .text("Image Source: https://thearchive.gg") + }) + }) + }) + .await?; + Ok(()) +} + +pub async fn banner(ctx: &Context, msg: &Message) -> CommandResult { + let author_name = format!("{}#{}", msg.author.name, msg.author.discriminator); + info!("{} requested banner information", author_name); + + let channel = msg.channel_id; + let banner_eng = BANNER.name.get(Language::English).unwrap(); + + channel + .send_message(ctx, |m| { + m.embed(|embed| { + embed + .image(BANNER_IMG_URL) + .title(BANNER.name.clone()) + .description(banner_eng) + .colour(BLUE_ARCHIVE_BLUE) + }) + }) + .await?; + + Ok(()) +} + +pub fn create_banner() -> Banner { + let pool: Vec = STUDENTS + .iter() + .filter(|student| student.name != "ノゾミ") + .cloned() + .collect(); + + let sparkable: Vec = pool + .iter() + .filter(|student| student.name == "ホシノ" || student.name == "シロコ") + .cloned() + .collect(); + + let gacha = GachaBuilder::new(79.0, 18.5, 2.5) + .with_pool(pool) + .with_priority(&sparkable, 0.7) + .finish() + .unwrap(); + + BannerBuilder::new("ピックアップ募集") + .with_gacha(&gacha) + .with_name_translation(Language::English, "Rate-Up Recruitment") + .with_sparkable_students(&sparkable) + .finish() + .unwrap() +} + +fn get_rarity_colour(rarity: Rarity) -> Colour { + match rarity { + Rarity::One => Colour::from_rgb(227, 234, 240), + Rarity::Two => Colour::from_rgb(255, 248, 124), + Rarity::Three => Colour::from_rgb(253, 198, 229), + } +}