Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 48 additions & 6 deletions src/discord/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub enum DiscordError {
Api,
}

const FALLBACK_EMBED_COLOR: u32 = 0x005eeb;

impl DiscordAPI {
pub async fn default() -> Self {
DiscordAPI::new(
Expand Down Expand Up @@ -107,7 +109,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());

Expand All @@ -120,7 +122,7 @@ impl DiscordAPI {
})
}

fn build_event_embed(
async fn build_event_embed(
event: Event,
ticket_shop_url: Option<String>,
ticket_shop_icon_url: &str,
Expand All @@ -138,17 +140,57 @@ impl DiscordAPI {
}

let embed_description = Self::truncate_embed_description(description);
let color = Self::get_image_dominant_color(&event.details.image_url)
.await
.unwrap_or_else(|| Colour::new(FALLBACK_EMBED_COLOR));

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<Colour> {
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, 8)
.map_err(|e| warn!("Failed extracting palette for '{}': {:?}", image_url, e))
.ok()?;

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) {
trace!("Adding reaction");

Expand Down Expand Up @@ -618,9 +660,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);
Expand Down
62 changes: 62 additions & 0 deletions tests/discord_embed_color_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
mod discord_embed_color {
use alertaemcena::agenda_cultural::api::AgendaCulturalAPI;
use alertaemcena::discord::api::DiscordAPI;
use serenity::all::Colour;

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;

match color {
Some(color) => assert_eq!(
color,
Colour::new(expected_hex),
"Dominant color for '{}' did not match expected",
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/",
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/",
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/",
0xC07A4F,
)
.await;
}
}
Loading