From 80e8c7bac069d13b77eddbd5a5a6358434c8a5f5 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:37:59 +0100 Subject: [PATCH 1/3] feat: add dominant color --- Cargo.toml | 4 +++ src/discord/api.rs | 40 +++++++++++++++++---- tests/discord_embed_color_tests.rs | 56 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 tests/discord_embed_color_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 1be9b40..62682a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ scraper = "0.19" # Discord serenity = "=0.12.4" +# Image processing +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +color-thief = "0.2.2" + # Utils uuid = { version = "1.11.1", features = ["v4"] } chrono = "0.4.40" diff --git a/src/discord/api.rs b/src/discord/api.rs index fe1b0d4..f62433b 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -107,7 +107,7 @@ impl DiscordAPI { info!(channel_id = %channel_id, event = %event.title, "Sending event"); let title = event.title.clone(); - let embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url); + let embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url).await; let message_builder = CreateMessage::new().add_embed(embed.clone()); @@ -120,7 +120,7 @@ impl DiscordAPI { }) } - fn build_event_embed( + async fn build_event_embed( event: Event, ticket_shop_url: Option, ticket_shop_icon_url: &str, @@ -138,17 +138,45 @@ impl DiscordAPI { } let embed_description = Self::truncate_embed_description(description); + let color = Self::get_image_dominant_color(&event.details.image_url) + .await + .unwrap_or(Colour::new(0x005eeb)); CreateEmbed::new() .title(event.title) .url(event.link) .description(embed_description) .author(author) - .color(Colour::new(0x005eeb)) + .color(color) .field("Datas", event.occurring_at.dates, true) .image(event.details.image_url) } + pub async fn get_image_dominant_color(image_url: &str) -> Option { + let bytes = reqwest::get(image_url) + .await + .map_err(|e| warn!("Failed fetching event image '{}': {}", image_url, e)) + .ok()? + .bytes() + .await + .map_err(|e| warn!("Failed reading event image bytes '{}': {}", image_url, e)) + .ok()?; + + let rgba = image::load_from_memory(&bytes) + .map_err(|e| warn!("Failed decoding event image '{}': {}", image_url, e)) + .ok()? + .to_rgba8(); + + let palette = + color_thief::get_palette(rgba.as_raw(), color_thief::ColorFormat::Rgba, 10, 5) + .map_err(|e| warn!("Failed extracting palette for '{}': {:?}", image_url, e)) + .ok()?; + + palette + .first() + .map(|c| Colour::from_rgb(c.r, c.g, c.b)) + } + pub async fn add_custom_reaction(&self, message: &Message, emoji: &EmojiConfig) { trace!("Adding reaction"); @@ -618,9 +646,9 @@ impl DiscordAPI { Ok(false) => {} } - let mut embed = - Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url) - .field("Voto", vote_emoji.to_string(), true); + let mut embed = Self::build_event_embed(event, ticket_shop_url, ticket_shop_icon_url) + .await + .field("Voto", vote_emoji.to_string(), true); if let Some(comment) = comment { embed = embed.field("Comentários", comment, true); diff --git a/tests/discord_embed_color_tests.rs b/tests/discord_embed_color_tests.rs new file mode 100644 index 0000000..16661c7 --- /dev/null +++ b/tests/discord_embed_color_tests.rs @@ -0,0 +1,56 @@ +mod discord_embed_color { + use alertaemcena::agenda_cultural::api::AgendaCulturalAPI; + use alertaemcena::discord::api::DiscordAPI; + use serenity::all::Colour; + + const FALLBACK_COLOR: Colour = Colour::new(0x005eeb); + + async fn assert_dominant_color_extracted(event_url: &str) { + let event = AgendaCulturalAPI::scrape_event(event_url) + .await + .unwrap_or_else(|| panic!("Failed to scrape event '{}'", event_url)); + + let color = DiscordAPI::get_image_dominant_color(&event.details.image_url).await; + + println!("Got {:?} for event '{}'", color.unwrap().hex(), event.details.image_url); + + match color { + Some(color) => assert_ne!( + color, FALLBACK_COLOR, + "Dominant color for '{}' matched the fallback color by coincidence or extraction failed silently", + event.details.image_url + ), + None => panic!( + "Failed to extract dominant color from event image '{}'", + event.details.image_url + ), + } + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_sonho_de_uma_noite_de_verao() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/sonho-de-uma-noite-de-verao-5/", + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_o_filho() { + assert_dominant_color_extracted("https://www.agendalx.pt/events/event/o-filho-2/").await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_o_coracao_de_um_pugilista() { + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/o-coracao-de-um-pugilista/", + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn should_extract_dominant_color_for_a_ratoeira() { + assert_dominant_color_extracted("https://www.agendalx.pt/events/event/a-ratoeira-5/") + .await; + } +} From 80f20303dad60b0450f2f3db7a4a982b49236fbc Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:50:00 +0100 Subject: [PATCH 2/3] feat: add filter for dominant color --- src/discord/api.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/discord/api.rs b/src/discord/api.rs index f62433b..734a381 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -57,6 +57,8 @@ pub enum DiscordError { Api, } +const FALLBACK_EMBED_COLOR: u32 = 0x005eeb; + impl DiscordAPI { pub async fn default() -> Self { DiscordAPI::new( @@ -140,7 +142,7 @@ impl DiscordAPI { let embed_description = Self::truncate_embed_description(description); let color = Self::get_image_dominant_color(&event.details.image_url) .await - .unwrap_or(Colour::new(0x005eeb)); + .unwrap_or_else(|| Colour::new(FALLBACK_EMBED_COLOR)); CreateEmbed::new() .title(event.title) @@ -168,13 +170,25 @@ impl DiscordAPI { .to_rgba8(); let palette = - color_thief::get_palette(rgba.as_raw(), color_thief::ColorFormat::Rgba, 10, 5) + color_thief::get_palette(rgba.as_raw(), color_thief::ColorFormat::Rgba, 10, 8) .map_err(|e| warn!("Failed extracting palette for '{}': {:?}", image_url, e)) .ok()?; - palette - .first() - .map(|c| Colour::from_rgb(c.r, c.g, c.b)) + let dominant = palette + .iter() + .find(|c| Self::is_colorful(c.r, c.g, c.b)) + .or_else(|| palette.first())?; + + Some(Colour::from_rgb(dominant.r, dominant.g, dominant.b)) + } + + /// Excludes near-grayscale colors so a vivid palette entry wins over a washed-out background. + fn is_colorful(r: u8, g: u8, b: u8) -> bool { + const SATURATION_THRESHOLD: u8 = 30; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + + max - min > SATURATION_THRESHOLD } pub async fn add_custom_reaction(&self, message: &Message, emoji: &EmojiConfig) { From 3efe4d95cd9e3a75cea897d688628e12a5522097 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:04:05 +0100 Subject: [PATCH 3/3] test: improve test match --- tests/discord_embed_color_tests.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/discord_embed_color_tests.rs b/tests/discord_embed_color_tests.rs index 16661c7..5b2ace2 100644 --- a/tests/discord_embed_color_tests.rs +++ b/tests/discord_embed_color_tests.rs @@ -3,21 +3,18 @@ mod discord_embed_color { use alertaemcena::discord::api::DiscordAPI; use serenity::all::Colour; - const FALLBACK_COLOR: Colour = Colour::new(0x005eeb); - - async fn assert_dominant_color_extracted(event_url: &str) { + async fn assert_dominant_color_extracted(event_url: &str, expected_hex: u32) { let event = AgendaCulturalAPI::scrape_event(event_url) .await .unwrap_or_else(|| panic!("Failed to scrape event '{}'", event_url)); let color = DiscordAPI::get_image_dominant_color(&event.details.image_url).await; - println!("Got {:?} for event '{}'", color.unwrap().hex(), event.details.image_url); - match color { - Some(color) => assert_ne!( - color, FALLBACK_COLOR, - "Dominant color for '{}' matched the fallback color by coincidence or extraction failed silently", + Some(color) => assert_eq!( + color, + Colour::new(expected_hex), + "Dominant color for '{}' did not match expected", event.details.image_url ), None => panic!( @@ -31,26 +28,35 @@ mod discord_embed_color { async fn should_extract_dominant_color_for_sonho_de_uma_noite_de_verao() { assert_dominant_color_extracted( "https://www.agendalx.pt/events/event/sonho-de-uma-noite-de-verao-5/", + 0x1B80B4, ) .await; } #[test_log::test(tokio::test)] async fn should_extract_dominant_color_for_o_filho() { - assert_dominant_color_extracted("https://www.agendalx.pt/events/event/o-filho-2/").await; + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/o-filho-2/", + 0x785D55, + ) + .await; } #[test_log::test(tokio::test)] async fn should_extract_dominant_color_for_o_coracao_de_um_pugilista() { assert_dominant_color_extracted( "https://www.agendalx.pt/events/event/o-coracao-de-um-pugilista/", + 0xCEB2A7, ) .await; } #[test_log::test(tokio::test)] async fn should_extract_dominant_color_for_a_ratoeira() { - assert_dominant_color_extracted("https://www.agendalx.pt/events/event/a-ratoeira-5/") - .await; + assert_dominant_color_extracted( + "https://www.agendalx.pt/events/event/a-ratoeira-5/", + 0xC07A4F, + ) + .await; } }