Add colors update
This commit is contained in:
+103
@@ -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>(())
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user