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]
name = "payphone"
version = "4.3.4"
version = "4.3.6"
edition = "2024"
default-run = "payphone"
[dependencies]
anyhow = "1.0.99"
async-trait = "0.1.88"
axum = "0.8.4"
clap = { version = "4.5.42", features = ["derive", "env", "string"] }
color-thief = "0.2.2"
colored = "3.0.0"
csv = "1.3.1"
deadpool-redis = { version = "0.22.0", features = ["cluster", "cluster-async"] }
dotenvy = "0.15.7"
image = "0.25.6"
include_dir = "0.7.4"
itertools = "0.14.0"
linkify = "0.10.0"
@@ -22,6 +25,8 @@ open = "5.3.2"
rand = "0.9.2"
rand_chacha = { version = "0.9.0", features = ["os_rng"] }
regex = "1.11.1"
reqwest = { version = "0.12.23", features = ["gzip", "brotli", "zstd"] }
rgb = "0.8.52"
scc = "2.3.4"
scylla = { version = "1.3.1", features = ["time-03"] }
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.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The default command prefix used by the bot for new servers.
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 time::{Duration, OffsetDateTime, UtcDateTime, UtcOffset};
use tokio::sync::mpsc::Sender;
use twilight_http::Client;
use twilight_model::{
channel::{Message as TwilightMessage, Webhook},
@@ -25,9 +26,10 @@ use twilight_model::{
};
use crate::{
colors::AvatarColorFetchingRequest,
constants::DEFAULT_PREFIX,
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)]
@@ -105,6 +107,8 @@ pub struct User {
pub supporter_until: Option<UtcDateTime>,
pub is_banned_from_leaderboard: Option<bool>,
pub findings: HashSet<String>,
pub avatar_hash: Option<String>,
pub color: Option<i32>,
}
impl<'a> Object<'a> for User {
@@ -120,6 +124,9 @@ impl<'a> Object<'a> for User {
Option<OffsetDateTime>,
Option<bool>,
HashSet<String>,
Option<String>,
Option<i32>,
Option<bool>,
);
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()),
is_banned_from_leaderboard: row.9,
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()),
Some(value.is_banned_from_leaderboard.unwrap_or(false)),
value.findings,
value.avatar_hash,
value.color,
Some(value.color.is_some()),
)
}
}
@@ -178,7 +190,10 @@ impl User {
supporter_since,
supporter_until,
is_banned_from_leaderboard,
findings
findings,
avatar_hash,
color,
is_color_set
FROM
payphone.users
WHERE
@@ -218,6 +233,7 @@ impl User {
pub async fn create_or_update(
user: TwilightUser,
session: &Session,
sender: &Sender<AvatarColorFetchingRequest>,
) -> Result<Self, DatabaseError> {
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.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);
}
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?;
Ok(new_user)
@@ -249,8 +293,11 @@ impl User {
supporter_since,
supporter_until,
is_banned_from_leaderboard,
findings
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
findings,
avatar_hash,
color,
is_color_set
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
Self::to_row(self.clone()),
)
@@ -338,6 +385,8 @@ impl From<TwilightUser> for User {
supporter_until: None,
is_banned_from_leaderboard: None,
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 {
if name.len() == 1 {
format!("{name}\u{2800}")
format!("{name}⠀⠀") // Add two invisible characters
} else if name.len() > 80 {
format!("{name:.80}")
} else {
+19 -2
View File
@@ -13,10 +13,17 @@ use twilight_model::util::ImageHash;
///
/// Returns:
/// [`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(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")
@@ -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,
{
pub fn new(
state: T,
state: Arc<T>,
prefixes: impl PrefixGetter<T, E> + Send + Sync + 'static,
extras: impl ExtrasGetter<T, Extras = E> + 'static,
) -> Self {
Self {
shard: ShardId::new(0, 1),
state: Arc::new(state),
state,
presence: None,
prefixes: Arc::new(prefixes),
intents: Intents::empty(),
+2 -2
View File
@@ -6,7 +6,7 @@ use twilight_util::builder::embed::{
};
use crate::{
constants::{COLOR_RED, COLOR_WHITE},
constants::{COLOR_RED, COLOR_WHITE, VERSION},
discord::{images, sanitizing::SanitizeDiscordMarkdown},
extras::Extras,
framework::{
@@ -106,7 +106,7 @@ pub(super) fn get_all_command_help_embed(
.unwrap())
.color(COLOR_WHITE)
.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()
}
+28 -8
View File
@@ -6,7 +6,6 @@ use time::{Duration, UtcDateTime};
use twilight_http::{api_error::ApiError, error::ErrorType};
use twilight_model::{
gateway::payload::incoming::{MessageCreate, MessageDelete, MessageUpdate},
guild,
id::Id,
};
use twilight_util::{
@@ -165,7 +164,12 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
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)
.await?
@@ -307,18 +311,18 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
.zip(replying_to.data.propagation_message_id)
{
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() {
get_mask_display_name(&mask)
get_mask_display_name(mask)
} else {
format!(
"{} ({})",
get_mask_display_name(&mask),
get_mask_display_name(mask),
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 {
(
@@ -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(
EmbedBuilder::new()
.author(
@@ -346,7 +353,15 @@ impl OnMessageHandler<State, Extras> for CallMessageHandler {
.icon_url(ImageSource::url(avatar_url).unwrap())
.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))
.build(),
);
@@ -508,7 +523,12 @@ impl OnMessageEditHandler<State, Extras> for CallMessageEditHandler {
.reply(event.id)
.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?;
@@ -22,6 +22,7 @@ ngiga
ngigas
negaras
nigy
nyga
diga
digas
fag
+18 -11
View File
@@ -105,7 +105,7 @@ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
path::Path,
process::Command,
sync::mpsc,
sync::Arc,
};
use async_trait::async_trait;
@@ -126,7 +126,8 @@ use twilight_model::{
use crate::{
cli::{Args, Commands},
constants::DEFAULT_PREFIX,
colors::init_color_fetching_queue_listener,
constants::{DEFAULT_PREFIX, VERSION},
database::models::{Server, User},
extras::Extras,
framework::{
@@ -137,7 +138,9 @@ use crate::{
groups::{
about::{commands::HelpCommand, components::HelpGoBackButton},
calls::{
commands::{CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand},
commands::{
CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand,
},
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
},
gateway::events::{ReadyHandler, ReconnectHandler},
@@ -152,6 +155,7 @@ use crate::{
};
mod cli;
mod colors;
mod constants;
mod database;
mod discord;
@@ -241,7 +245,8 @@ impl ExtrasGetter<State> for PayphoneExtrasGetter {
) -> Result<Self::Extras, Box<dyn Error>> {
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 })
}
@@ -258,10 +263,7 @@ async fn start(
bot_owner_id: u64,
) {
log::info!("Welcome to Payphone, Nyeki!");
log::info!(
"Payphone is running on version v{}",
env!("CARGO_PKG_VERSION")
);
log::info!("Payphone is running on version v{VERSION}");
let db = database::init(database_addr, database_username, database_password)
.await
@@ -273,11 +275,16 @@ async fn start(
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,
redis: redis_pool,
bot_owner_id,
};
color_queue_sender,
});
init_color_fetching_queue_listener(color_queue_receiver, state.clone());
let bot = Bot::new(state, ServerPrefixOrPayphoneMention, PayphoneExtrasGetter)
.shard(shard)
@@ -352,7 +359,7 @@ async fn docs(bind: SocketAddr, open_on_start: bool) {
.expect("Failed to run `cargo doc`");
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");
+4
View File
@@ -20,9 +20,13 @@
use deadpool_redis::cluster::Pool;
use scylla::client::session::Session;
use tokio::sync::mpsc::Sender;
use crate::colors::AvatarColorFetchingRequest;
pub struct State {
pub db: Session,
pub redis: Pool,
pub bot_owner_id: u64,
pub color_queue_sender: Sender<AvatarColorFetchingRequest>,
}