Add colors update

This commit is contained in:
2025-08-24 20:03:52 -03:00
parent 39120a65fa
commit 3f5ec4f258
13 changed files with 1304 additions and 50 deletions
Generated
+1057 -19
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,17 +1,20 @@
[package] [package]
name = "payphone" name = "payphone"
version = "4.3.4" version = "4.3.6"
edition = "2024" edition = "2024"
default-run = "payphone" default-run = "payphone"
[dependencies] [dependencies]
anyhow = "1.0.99"
async-trait = "0.1.88" async-trait = "0.1.88"
axum = "0.8.4" axum = "0.8.4"
clap = { version = "4.5.42", features = ["derive", "env", "string"] } clap = { version = "4.5.42", features = ["derive", "env", "string"] }
color-thief = "0.2.2"
colored = "3.0.0" colored = "3.0.0"
csv = "1.3.1" csv = "1.3.1"
deadpool-redis = { version = "0.22.0", features = ["cluster", "cluster-async"] } deadpool-redis = { version = "0.22.0", features = ["cluster", "cluster-async"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
image = "0.25.6"
include_dir = "0.7.4" include_dir = "0.7.4"
itertools = "0.14.0" itertools = "0.14.0"
linkify = "0.10.0" linkify = "0.10.0"
@@ -22,6 +25,8 @@ open = "5.3.2"
rand = "0.9.2" rand = "0.9.2"
rand_chacha = { version = "0.9.0", features = ["os_rng"] } rand_chacha = { version = "0.9.0", features = ["os_rng"] }
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.23", features = ["gzip", "brotli", "zstd"] }
rgb = "0.8.52"
scc = "2.3.4" scc = "2.3.4"
scylla = { version = "1.3.1", features = ["time-03"] } scylla = { version = "1.3.1", features = ["time-03"] }
serde_json = "1.0.143" serde_json = "1.0.143"
+103
View File
@@ -0,0 +1,103 @@
use std::{io::Cursor, sync::Arc};
use color_thief::ColorFormat;
use image::{GenericImageView, ImageReader};
use reqwest::header::USER_AGENT;
use rgb::Rgb;
use tokio::sync::{Semaphore, mpsc::Receiver};
use crate::{
constants::VERSION, database::models::User,
discord::images::get_user_custom_avatar_url_or_none, state::State,
};
pub struct AvatarColorFetchingRequest {
pub hash: String,
pub user_id: u64,
}
impl AvatarColorFetchingRequest {
pub fn new(hash: impl ToString, user_id: u64) -> Self {
Self {
hash: hash.to_string(),
user_id,
}
}
}
pub fn init_color_fetching_queue_listener(
mut receiver: Receiver<AvatarColorFetchingRequest>,
state: Arc<State>,
) {
tokio::spawn(async move {
let semaphore = Arc::new(Semaphore::new(5));
while let Some(request) = receiver.recv().await {
let task_semaphore = semaphore.clone();
let task_state = state.clone();
tokio::spawn(async move {
let _permit = task_semaphore
.acquire()
.await
.inspect_err(|e| log::error!("Failed to acquire semaphore permit: {e}"))
.expect("Failed to acquire semaphore permit");
let avatar_url = get_user_custom_avatar_url_or_none(request.user_id, request.hash);
let image_bytes = reqwest::Client::new()
.get(avatar_url)
.header(USER_AGENT, format!("Payphone/v{VERSION}"))
.send()
.await
.inspect_err(|e| log::error!("Failed to fetch avatar color: {e}"))?
.error_for_status()?
.bytes()
.await
.inspect_err(|e| log::error!("Failed to read avatar image bytes: {e}"))?;
let color = tokio::task::spawn_blocking(move || {
let img = ImageReader::new(Cursor::new(image_bytes))
.with_guessed_format()?
.decode()?;
let mut pixels = Vec::new();
for (_x, _y, rgba) in img.pixels() {
pixels.extend_from_slice(&rgba.0);
}
drop(img);
let palette = color_thief::get_palette(&pixels, ColorFormat::Rgba, 1, 2)?;
Ok::<_, anyhow::Error>(palette.first().cloned())
})
.await??;
if let Some(color) = color {
let Rgb { r, g, b } = color;
let color_int: u32 = ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
let mut user = User::get_by_id(request.user_id, &task_state.db)
.await
.inspect_err(|e| {
log::error!("Failed to fetch user to set their new avatar color: {e}")
})?
.ok_or(anyhow::anyhow!(
"User with ID {} not found when fetching their avatar URL color",
request.user_id
))
.inspect_err(|e| log::error!("{e}"))?;
user.color = Some(color_int as i32);
user.insert(&task_state.db).await?;
}
Ok::<_, anyhow::Error>(())
});
}
});
}
+2
View File
@@ -1,5 +1,7 @@
//! Constants used throughout the Payphone project. //! Constants used throughout the Payphone project.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The default command prefix used by the bot for new servers. /// The default command prefix used by the bot for new servers.
pub const DEFAULT_PREFIX: &str = "p."; pub const DEFAULT_PREFIX: &str = "p.";
@@ -0,0 +1,8 @@
ALTER TABLE payphone.users ADD avatar_hash TEXT;
ALTER TABLE payphone.users ADD color INT;
ALTER TABLE payphone.users ADD is_color_set BOOLEAN;
CREATE MATERIALIZED VIEW payphone.users_with_no_color AS
SELECT user_id, avatar_hash, is_color_set FROM payphone.users
WHERE user_id IS NOT NULL AND is_color_set IS NOT NULL
PRIMARY KEY (is_color_set, user_id);
+54 -5
View File
@@ -16,6 +16,7 @@ use scylla::{
}; };
use thiserror::Error; use thiserror::Error;
use time::{Duration, OffsetDateTime, UtcDateTime, UtcOffset}; use time::{Duration, OffsetDateTime, UtcDateTime, UtcOffset};
use tokio::sync::mpsc::Sender;
use twilight_http::Client; use twilight_http::Client;
use twilight_model::{ use twilight_model::{
channel::{Message as TwilightMessage, Webhook}, channel::{Message as TwilightMessage, Webhook},
@@ -25,9 +26,10 @@ use twilight_model::{
}; };
use crate::{ use crate::{
colors::AvatarColorFetchingRequest,
constants::DEFAULT_PREFIX, constants::DEFAULT_PREFIX,
groups::calls::masks::{get_mask_avatar_url, get_mask_display_name, get_random_mask}, groups::calls::masks::{get_mask_avatar_url, get_mask_display_name, get_random_mask},
utils::{get_random_u64, i64_to_u64, u64_to_i64}, utils::{Bye, get_random_u64, i64_to_u64, u64_to_i64},
}; };
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -105,6 +107,8 @@ pub struct User {
pub supporter_until: Option<UtcDateTime>, pub supporter_until: Option<UtcDateTime>,
pub is_banned_from_leaderboard: Option<bool>, pub is_banned_from_leaderboard: Option<bool>,
pub findings: HashSet<String>, pub findings: HashSet<String>,
pub avatar_hash: Option<String>,
pub color: Option<i32>,
} }
impl<'a> Object<'a> for User { impl<'a> Object<'a> for User {
@@ -120,6 +124,9 @@ impl<'a> Object<'a> for User {
Option<OffsetDateTime>, Option<OffsetDateTime>,
Option<bool>, Option<bool>,
HashSet<String>, HashSet<String>,
Option<String>,
Option<i32>,
Option<bool>,
); );
fn from_row(row: Self::Row) -> Self { fn from_row(row: Self::Row) -> Self {
@@ -135,6 +142,8 @@ impl<'a> Object<'a> for User {
supporter_until: row.8.map(|i| i.into()), supporter_until: row.8.map(|i| i.into()),
is_banned_from_leaderboard: row.9, is_banned_from_leaderboard: row.9,
findings: row.10, findings: row.10,
avatar_hash: row.11,
color: row.12,
} }
} }
@@ -151,6 +160,9 @@ impl<'a> Object<'a> for User {
value.supporter_until.map(|i| i.into()), value.supporter_until.map(|i| i.into()),
Some(value.is_banned_from_leaderboard.unwrap_or(false)), Some(value.is_banned_from_leaderboard.unwrap_or(false)),
value.findings, value.findings,
value.avatar_hash,
value.color,
Some(value.color.is_some()),
) )
} }
} }
@@ -178,7 +190,10 @@ impl User {
supporter_since, supporter_since,
supporter_until, supporter_until,
is_banned_from_leaderboard, is_banned_from_leaderboard,
findings findings,
avatar_hash,
color,
is_color_set
FROM FROM
payphone.users payphone.users
WHERE WHERE
@@ -218,6 +233,7 @@ impl User {
pub async fn create_or_update( pub async fn create_or_update(
user: TwilightUser, user: TwilightUser,
session: &Session, session: &Session,
sender: &Sender<AvatarColorFetchingRequest>,
) -> Result<Self, DatabaseError> { ) -> Result<Self, DatabaseError> {
let db_user = User::get_by_id(user.id.get(), session).await?; let db_user = User::get_by_id(user.id.get(), session).await?;
@@ -225,10 +241,38 @@ impl User {
db_user.last_seen_at = UtcDateTime::now(); db_user.last_seen_at = UtcDateTime::now();
db_user.name = user.name; db_user.name = user.name;
if user.avatar.map(|hash| hash.to_string()) != db_user.avatar_hash {
db_user.avatar_hash = user.avatar.map(|hash| hash.to_string());
db_user.color = None;
if let Some(avatar_hash) = &db_user.avatar_hash {
sender
.send(AvatarColorFetchingRequest::new(avatar_hash, user.id.get()))
.await
.inspect_err(|e| {
log::error!(
"Failed to send avatar color fetching request to queue! {e}"
)
})
.bye();
}
}
return Ok(db_user); return Ok(db_user);
} }
let new_user = User::from(user); let new_user = User::from(user);
if let Some(avatar_hash) = &new_user.avatar_hash {
sender
.send(AvatarColorFetchingRequest::new(avatar_hash, new_user.id))
.await
.inspect_err(|e| {
log::error!("Failed to send avatar color fetching request to queue! {e}")
})
.bye();
}
new_user.insert(session).await?; new_user.insert(session).await?;
Ok(new_user) Ok(new_user)
@@ -249,8 +293,11 @@ impl User {
supporter_since, supporter_since,
supporter_until, supporter_until,
is_banned_from_leaderboard, is_banned_from_leaderboard,
findings findings,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); avatar_hash,
color,
is_color_set
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#, "#,
Self::to_row(self.clone()), Self::to_row(self.clone()),
) )
@@ -338,6 +385,8 @@ impl From<TwilightUser> for User {
supporter_until: None, supporter_until: None,
is_banned_from_leaderboard: None, is_banned_from_leaderboard: None,
findings: HashSet::new(), findings: HashSet::new(),
avatar_hash: value.avatar.map(|a| a.to_string()),
color: None,
} }
} }
} }
@@ -1865,7 +1914,7 @@ impl<'a> Object<'a> for Participant {
fn make_name_valid(name: String) -> String { fn make_name_valid(name: String) -> String {
if name.len() == 1 { if name.len() == 1 {
format!("{name}\u{2800}") format!("{name}⠀⠀") // Add two invisible characters
} else if name.len() > 80 { } else if name.len() > 80 {
format!("{name:.80}") format!("{name:.80}")
} else { } else {
+19 -2
View File
@@ -13,10 +13,17 @@ use twilight_model::util::ImageHash;
/// ///
/// Returns: /// Returns:
/// [`String`] - The URL of the user's avatar. /// [`String`] - The URL of the user's avatar.
pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64, guild_id: Option<u64>) -> String { pub fn get_avatar_url(
hash: Option<ImageHash>,
user_id: u64,
discriminator: u64,
guild_id: Option<u64>,
) -> String {
if let Some(hash) = hash { if let Some(hash) = hash {
if let Some(guild_id) = guild_id { if let Some(guild_id) = guild_id {
return format!("https://cdn.discordapp.com/guilds/{guild_id}/users/{user_id}/avatars/{hash}.webp?size=256"); return format!(
"https://cdn.discordapp.com/guilds/{guild_id}/users/{user_id}/avatars/{hash}.webp?size=256"
);
} }
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256") format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
@@ -30,3 +37,13 @@ pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64,
) )
} }
} }
/// Returns the user's avatar URL only if it's custom (i.e. not the default avatar). It doesn't
/// take into account custom per-server avatars, i.e. only the global avatar is returned if any.
///
/// Arguments:
/// * `user_id` - The user's ID.
/// * `hash` - The hex-encoded hash string, if any.
pub fn get_user_custom_avatar_url_or_none(user_id: u64, hash: String) -> String {
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
}
+2 -2
View File
@@ -119,13 +119,13 @@ where
E: 'static + Send + Sync, E: 'static + Send + Sync,
{ {
pub fn new( pub fn new(
state: T, state: Arc<T>,
prefixes: impl PrefixGetter<T, E> + Send + Sync + 'static, prefixes: impl PrefixGetter<T, E> + Send + Sync + 'static,
extras: impl ExtrasGetter<T, Extras = E> + 'static, extras: impl ExtrasGetter<T, Extras = E> + 'static,
) -> Self { ) -> Self {
Self { Self {
shard: ShardId::new(0, 1), shard: ShardId::new(0, 1),
state: Arc::new(state), state,
presence: None, presence: None,
prefixes: Arc::new(prefixes), prefixes: Arc::new(prefixes),
intents: Intents::empty(), intents: Intents::empty(),
+2 -2
View File
@@ -6,7 +6,7 @@ use twilight_util::builder::embed::{
}; };
use crate::{ use crate::{
constants::{COLOR_RED, COLOR_WHITE}, constants::{COLOR_RED, COLOR_WHITE, VERSION},
discord::{images, sanitizing::SanitizeDiscordMarkdown}, discord::{images, sanitizing::SanitizeDiscordMarkdown},
extras::Extras, extras::Extras,
framework::{ framework::{
@@ -106,7 +106,7 @@ pub(super) fn get_all_command_help_embed(
.unwrap()) .unwrap())
.color(COLOR_WHITE) .color(COLOR_WHITE)
.field(EmbedFieldBuilder::new("Commands", get_commands_table(rt, extras)).build()) .field(EmbedFieldBuilder::new("Commands", get_commands_table(rt, extras)).build())
.footer(EmbedFooterBuilder::new(format!("v{} - Use {}help <command> to get more information about a specific command.", env!("CARGO_PKG_VERSION"), prefix))) .footer(EmbedFooterBuilder::new(format!("v{VERSION} - Use {}help <command> to get more information about a specific command.", prefix)))
.build() .build()
} }
+28 -8
View File
@@ -6,7 +6,6 @@ use time::{Duration, UtcDateTime};
use twilight_http::{api_error::ApiError, error::ErrorType}; use twilight_http::{api_error::ApiError, error::ErrorType};
use twilight_model::{ use twilight_model::{
gateway::payload::incoming::{MessageCreate, MessageDelete, MessageUpdate}, gateway::payload::incoming::{MessageCreate, MessageDelete, MessageUpdate},
guild,
id::Id, id::Id,
}; };
use twilight_util::{ use twilight_util::{
@@ -165,7 +164,12 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
return Ok(()); return Ok(());
}; };
let mut user = User::create_or_update(event.author.clone(), &rt.state.db).await?; let mut user = User::create_or_update(
event.author.clone(),
&rt.state.db,
&rt.state.color_queue_sender,
)
.await?;
let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db) let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db)
.await? .await?
@@ -307,18 +311,18 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
.zip(replying_to.data.propagation_message_id) .zip(replying_to.data.propagation_message_id)
{ {
let (display_name, avatar_url) = let (display_name, avatar_url) =
if let Some(mask) = replying_to.data.author_mask { if let Some(mask) = &replying_to.data.author_mask {
( (
if i64_to_u64(propagation_channel_id) != event.channel_id.get() { if i64_to_u64(propagation_channel_id) != event.channel_id.get() {
get_mask_display_name(&mask) get_mask_display_name(mask)
} else { } else {
format!( format!(
"{} ({})", "{} ({})",
get_mask_display_name(&mask), get_mask_display_name(mask),
replying_to.data.author_name replying_to.data.author_name
) )
}, },
get_mask_avatar_url(&mask).expect("Failed to get mask avatar URL"), get_mask_avatar_url(mask).expect("Failed to get mask avatar URL"),
) )
} else { } else {
( (
@@ -339,6 +343,9 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
) )
}; };
let replying_to_user =
User::get_by_id(replying_to.author_id, &rt.state.db).await?;
embeds.push( embeds.push(
EmbedBuilder::new() EmbedBuilder::new()
.author( .author(
@@ -346,7 +353,15 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
.icon_url(ImageSource::url(avatar_url).unwrap()) .icon_url(ImageSource::url(avatar_url).unwrap())
.url(message_link), .url(message_link),
) )
.color(COLOR_WHITE) .color(
if replying_to.data.author_mask.is_some() {
COLOR_WHITE
} else {
replying_to_user
.and_then(|i| i.color.map(|c| c as u32))
.unwrap_or(COLOR_WHITE)
}
)
.description(ellipse(replying_to.data.content, 100)) .description(ellipse(replying_to.data.content, 100))
.build(), .build(),
); );
@@ -508,7 +523,12 @@ impl OnMessageEditHandler<State, Extras> for CallMessageEditHandler {
.reply(event.id) .reply(event.id)
.await?; .await?;
let user = User::create_or_update(event.author.clone(), &rt.state.db).await?; let user = User::create_or_update(
event.author.clone(),
&rt.state.db,
&rt.state.color_queue_sender,
)
.await?;
penalize_user(user, message, rules, &rt).await?; penalize_user(user, message, rules, &rt).await?;
@@ -22,6 +22,7 @@ ngiga
ngigas ngigas
negaras negaras
nigy nigy
nyga
diga diga
digas digas
fag fag
+18 -11
View File
@@ -105,7 +105,7 @@ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
path::Path, path::Path,
process::Command, process::Command,
sync::mpsc, sync::Arc,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -126,7 +126,8 @@ use twilight_model::{
use crate::{ use crate::{
cli::{Args, Commands}, cli::{Args, Commands},
constants::DEFAULT_PREFIX, colors::init_color_fetching_queue_listener,
constants::{DEFAULT_PREFIX, VERSION},
database::models::{Server, User}, database::models::{Server, User},
extras::Extras, extras::Extras,
framework::{ framework::{
@@ -137,7 +138,9 @@ use crate::{
groups::{ groups::{
about::{commands::HelpCommand, components::HelpGoBackButton}, about::{commands::HelpCommand, components::HelpGoBackButton},
calls::{ calls::{
commands::{CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand}, commands::{
CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand,
},
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
}, },
gateway::events::{ReadyHandler, ReconnectHandler}, gateway::events::{ReadyHandler, ReconnectHandler},
@@ -152,6 +155,7 @@ use crate::{
}; };
mod cli; mod cli;
mod colors;
mod constants; mod constants;
mod database; mod database;
mod discord; mod discord;
@@ -241,7 +245,8 @@ impl ExtrasGetter<State> for PayphoneExtrasGetter {
) -> Result<Self::Extras, Box<dyn Error>> { ) -> Result<Self::Extras, Box<dyn Error>> {
log::trace!("Getting extras!"); log::trace!("Getting extras!");
let user = User::create_or_update(user.clone(), &rt.state.db).await?; let user = User::create_or_update(user.clone(), &rt.state.db, &rt.state.color_queue_sender)
.await?;
Ok(Extras { user }) Ok(Extras { user })
} }
@@ -258,10 +263,7 @@ async fn start(
bot_owner_id: u64, bot_owner_id: u64,
) { ) {
log::info!("Welcome to Payphone, Nyeki!"); log::info!("Welcome to Payphone, Nyeki!");
log::info!( log::info!("Payphone is running on version v{VERSION}");
"Payphone is running on version v{}",
env!("CARGO_PKG_VERSION")
);
let db = database::init(database_addr, database_username, database_password) let db = database::init(database_addr, database_username, database_password)
.await .await
@@ -273,11 +275,16 @@ async fn start(
log::info!("Redis connection established successfully."); log::info!("Redis connection established successfully.");
let state = State { let (color_queue_sender, color_queue_receiver) = tokio::sync::mpsc::channel(100);
let state = Arc::new(State {
db, db,
redis: redis_pool, redis: redis_pool,
bot_owner_id, bot_owner_id,
}; color_queue_sender,
});
init_color_fetching_queue_listener(color_queue_receiver, state.clone());
let bot = Bot::new(state, ServerPrefixOrPayphoneMention, PayphoneExtrasGetter) let bot = Bot::new(state, ServerPrefixOrPayphoneMention, PayphoneExtrasGetter)
.shard(shard) .shard(shard)
@@ -352,7 +359,7 @@ async fn docs(bind: SocketAddr, open_on_start: bool) {
.expect("Failed to run `cargo doc`"); .expect("Failed to run `cargo doc`");
tokio::spawn(async { tokio::spawn(async {
let (tx, rx) = mpsc::channel::<notify::Result<Event>>(); let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(tx).expect("Failed to create watcher"); let mut watcher = notify::recommended_watcher(tx).expect("Failed to create watcher");
+4
View File
@@ -20,9 +20,13 @@
use deadpool_redis::cluster::Pool; use deadpool_redis::cluster::Pool;
use scylla::client::session::Session; use scylla::client::session::Session;
use tokio::sync::mpsc::Sender;
use crate::colors::AvatarColorFetchingRequest;
pub struct State { pub struct State {
pub db: Session, pub db: Session,
pub redis: Pool, pub redis: Pool,
pub bot_owner_id: u64, pub bot_owner_id: u64,
pub color_queue_sender: Sender<AvatarColorFetchingRequest>,
} }