Public discord webhook (#492)

This commit is contained in:
Geometrically 2022-12-06 19:51:03 -07:00 committed by GitHub
parent e96d23cc3f
commit e809f77461
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1391 additions and 905 deletions

1
.env
View File

@ -9,6 +9,7 @@ LABRINTH_ADMIN_KEY=feedbeef
RATE_LIMIT_IGNORE_KEY=feedbeef RATE_LIMIT_IGNORE_KEY=feedbeef
MODERATION_DISCORD_WEBHOOK= MODERATION_DISCORD_WEBHOOK=
PUBLIC_DISCORD_WEBHOOK=
CLOUDFLARE_INTEGRATION=false CLOUDFLARE_INTEGRATION=false
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth

View File

@ -66,7 +66,7 @@ log = "0.4.17"
env_logger = "0.9.1" env_logger = "0.9.1"
thiserror = "1.0.37" thiserror = "1.0.37"
sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "chrono", "offline", "macros", "migrate", "decimal"] } sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "chrono", "offline", "macros", "migrate", "decimal", "json"] }
rust_decimal = { version = "1.26", features = ["serde-with-float", "serde-with-str"] } rust_decimal = { version = "1.26", features = ["serde-with-float", "serde-with-str"] }
sentry = "0.28.0" sentry = "0.28.0"

View File

@ -0,0 +1,9 @@
-- Add migration script here
ALTER TABLE mods ADD COLUMN webhook_sent BOOL NOT NULL DEFAULT FALSE;
UPDATE mods
SET webhook_sent = (status = 'approved');
UPDATE mods
SET status = 'withheld'
WHERE status = 'unlisted';

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ use super::DatabaseError;
use chrono::DateTime; use chrono::DateTime;
use chrono::Utc; use chrono::Utc;
use futures::TryStreamExt; use futures::TryStreamExt;
use serde::Deserialize;
pub struct ProjectType { pub struct ProjectType {
pub id: ProjectTypeId, pub id: ProjectTypeId,
@ -16,12 +17,13 @@ pub struct Loader {
pub supported_project_types: Vec<String>, pub supported_project_types: Vec<String>,
} }
#[derive(Clone)] #[derive(Clone, Deserialize, Debug)]
pub struct GameVersion { pub struct GameVersion {
pub id: GameVersionId, pub id: GameVersionId,
pub version: String, pub version: String,
pub version_type: String, #[serde(rename = "type")]
pub date: DateTime<Utc>, pub type_: String,
pub created: DateTime<Utc>,
pub major: bool, pub major: bool,
} }
@ -507,8 +509,8 @@ impl GameVersion {
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id), id: GameVersionId(c.id),
version: c.version_, version: c.version_,
version_type: c.type_, type_: c.type_,
date: c.created, created: c.created,
major: c.major major: c.major
})) }) })) })
.try_collect::<Vec<GameVersion>>() .try_collect::<Vec<GameVersion>>()
@ -542,8 +544,8 @@ impl GameVersion {
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id), id: GameVersionId(c.id),
version: c.version_, version: c.version_,
version_type: c.type_, type_: c.type_,
date: c.created, created: c.created,
major: c.major, major: c.major,
})) }) })) })
.try_collect::<Vec<GameVersion>>() .try_collect::<Vec<GameVersion>>()
@ -561,8 +563,8 @@ impl GameVersion {
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id), id: GameVersionId(c.id),
version: c.version_, version: c.version_,
version_type: c.type_, type_: c.type_,
date: c.created, created: c.created,
major: c.major, major: c.major,
})) }) })) })
.try_collect::<Vec<GameVersion>>() .try_collect::<Vec<GameVersion>>()
@ -581,8 +583,8 @@ impl GameVersion {
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id), id: GameVersionId(c.id),
version: c.version_, version: c.version_,
version_type: c.type_, type_: c.type_,
date: c.created, created: c.created,
major: c.major, major: c.major,
})) }) })) })
.try_collect::<Vec<GameVersion>>() .try_collect::<Vec<GameVersion>>()

View File

@ -2,6 +2,7 @@ use super::DatabaseError;
use crate::models::ids::base62_impl::to_base62; use crate::models::ids::base62_impl::to_base62;
use crate::models::ids::random_base62_rng; use crate::models::ids::random_base62_rng;
use censor::Censor; use censor::Censor;
use serde::Deserialize;
use sqlx::sqlx_macros::Type; use sqlx::sqlx_macros::Type;
const ID_RETRY_COUNT: usize = 20; const ID_RETRY_COUNT: usize = 20;
@ -136,7 +137,7 @@ pub struct DonationPlatformId(pub i32);
#[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash)] #[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash)]
#[sqlx(transparent)] #[sqlx(transparent)]
pub struct VersionId(pub i64); pub struct VersionId(pub i64);
#[derive(Copy, Clone, Debug, Type)] #[derive(Copy, Clone, Debug, Type, Deserialize)]
#[sqlx(transparent)] #[sqlx(transparent)]
pub struct GameVersionId(pub i32); pub struct GameVersionId(pub i32);
#[derive(Copy, Clone, Debug, Type)] #[derive(Copy, Clone, Debug, Type)]

View File

@ -134,6 +134,7 @@ impl ProjectBuilder {
moderation_message_body: None, moderation_message_body: None,
flame_anvil_project: None, flame_anvil_project: None,
flame_anvil_user: None, flame_anvil_user: None,
webhook_sent: false,
}; };
project_struct.insert(&mut *transaction).await?; project_struct.insert(&mut *transaction).await?;
@ -211,6 +212,7 @@ pub struct Project {
pub moderation_message_body: Option<String>, pub moderation_message_body: Option<String>,
pub flame_anvil_project: Option<i32>, pub flame_anvil_project: Option<i32>,
pub flame_anvil_user: Option<UserId>, pub flame_anvil_user: Option<UserId>,
pub webhook_sent: bool,
} }
impl Project { impl Project {
@ -277,7 +279,7 @@ impl Project {
issues_url, source_url, wiki_url, discord_url, license_url, issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug, team_id, client_side, server_side, license, slug,
moderation_message, moderation_message_body, flame_anvil_project, moderation_message, moderation_message_body, flame_anvil_project,
flame_anvil_user flame_anvil_user, webhook_sent
FROM mods FROM mods
WHERE id = $1 WHERE id = $1
", ",
@ -318,6 +320,7 @@ impl Project {
approved: row.approved, approved: row.approved,
flame_anvil_project: row.flame_anvil_project, flame_anvil_project: row.flame_anvil_project,
flame_anvil_user: row.flame_anvil_user.map(UserId), flame_anvil_user: row.flame_anvil_user.map(UserId),
webhook_sent: row.webhook_sent,
})) }))
} else { } else {
Ok(None) Ok(None)
@ -343,7 +346,7 @@ impl Project {
issues_url, source_url, wiki_url, discord_url, license_url, issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug, team_id, client_side, server_side, license, slug,
moderation_message, moderation_message_body, flame_anvil_project, moderation_message, moderation_message_body, flame_anvil_project,
flame_anvil_user flame_anvil_user, webhook_sent
FROM mods FROM mods
WHERE id = ANY($1) WHERE id = ANY($1)
", ",
@ -384,6 +387,7 @@ impl Project {
approved: m.approved, approved: m.approved,
flame_anvil_project: m.flame_anvil_project, flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId), flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
})) }))
}) })
.try_collect::<Vec<Project>>() .try_collect::<Vec<Project>>()
@ -662,7 +666,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status, m.requested_status requested_status, m.updated updated, m.approved approved, m.status status, m.requested_status requested_status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent webhook_sent,
ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories, ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories,
ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions, ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions,
ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery, ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery,
@ -736,6 +740,7 @@ impl Project {
approved: m.approved, approved: m.approved,
flame_anvil_project: m.flame_anvil_project, flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId), flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
}, },
project_type: m.project_type_name, project_type: m.project_type_name,
categories, categories,
@ -848,7 +853,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status, m.requested_status requested_status, m.updated updated, m.approved approved, m.status status, m.requested_status requested_status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent,
ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories, ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories,
ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions, ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions,
ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery, ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery,
@ -925,7 +930,8 @@ impl Project {
moderation_message_body: m.moderation_message_body, moderation_message_body: m.moderation_message_body,
approved: m.approved, approved: m.approved,
flame_anvil_project: m.flame_anvil_project, flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId) flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
}, },
project_type: m.project_type_name, project_type: m.project_type_name,
categories, categories,

View File

@ -493,6 +493,7 @@ impl From<QueryVersion> for Version {
/// A status decides the visibility of a project in search, URLs, and the whole site itself. /// A status decides the visibility of a project in search, URLs, and the whole site itself.
/// Listed - Version is displayed on project, and accessible by URL /// Listed - Version is displayed on project, and accessible by URL
/// Archived - Identical to listed but has a message displayed stating version is unsupported
/// Draft - Version is not displayed on project, and not accessible by URL /// Draft - Version is not displayed on project, and not accessible by URL
/// Unlisted - Version is not displayed on project, and accessible by URL /// Unlisted - Version is not displayed on project, and accessible by URL
/// Scheduled - Version is scheduled to be released in the future /// Scheduled - Version is scheduled to be released in the future
@ -500,6 +501,7 @@ impl From<QueryVersion> for Version {
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum VersionStatus { pub enum VersionStatus {
Listed, Listed,
Archived,
Draft, Draft,
Unlisted, Unlisted,
Scheduled, Scheduled,
@ -518,12 +520,14 @@ impl VersionStatus {
"listed" => VersionStatus::Listed, "listed" => VersionStatus::Listed,
"draft" => VersionStatus::Draft, "draft" => VersionStatus::Draft,
"unlisted" => VersionStatus::Unlisted, "unlisted" => VersionStatus::Unlisted,
"scheduled" => VersionStatus::Scheduled,
_ => VersionStatus::Unknown, _ => VersionStatus::Unknown,
} }
} }
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
VersionStatus::Listed => "listed", VersionStatus::Listed => "listed",
VersionStatus::Archived => "archived",
VersionStatus::Draft => "draft", VersionStatus::Draft => "draft",
VersionStatus::Unlisted => "unlisted", VersionStatus::Unlisted => "unlisted",
VersionStatus::Unknown => "unknown", VersionStatus::Unknown => "unknown",
@ -534,6 +538,7 @@ impl VersionStatus {
pub fn iterator() -> impl Iterator<Item = VersionStatus> { pub fn iterator() -> impl Iterator<Item = VersionStatus> {
[ [
VersionStatus::Listed, VersionStatus::Listed,
VersionStatus::Archived,
VersionStatus::Draft, VersionStatus::Draft,
VersionStatus::Unlisted, VersionStatus::Unlisted,
VersionStatus::Scheduled, VersionStatus::Scheduled,
@ -547,6 +552,7 @@ impl VersionStatus {
pub fn is_hidden(&self) -> bool { pub fn is_hidden(&self) -> bool {
match self { match self {
VersionStatus::Listed => false, VersionStatus::Listed => false,
VersionStatus::Archived => false,
VersionStatus::Unlisted => false, VersionStatus::Unlisted => false,
VersionStatus::Draft => true, VersionStatus::Draft => true,
@ -557,13 +563,14 @@ impl VersionStatus {
// Whether version is listed on project / returned in aggregate routes // Whether version is listed on project / returned in aggregate routes
pub fn is_listed(&self) -> bool { pub fn is_listed(&self) -> bool {
matches!(self, VersionStatus::Listed) matches!(self, VersionStatus::Listed | VersionStatus::Archived)
} }
// Whether a version status can be requested // Whether a version status can be requested
pub fn can_be_requested(&self) -> bool { pub fn can_be_requested(&self) -> bool {
match self { match self {
VersionStatus::Listed => true, VersionStatus::Listed => true,
VersionStatus::Archived => true,
VersionStatus::Draft => true, VersionStatus::Draft => true,
VersionStatus::Unlisted => true, VersionStatus::Unlisted => true,
VersionStatus::Scheduled => false, VersionStatus::Scheduled => false,

View File

@ -226,6 +226,8 @@ pub enum ApiError {
Crypto(String), Crypto(String),
#[error("Payments Error: {0}")] #[error("Payments Error: {0}")]
Payments(String), Payments(String),
#[error("Discord Error: {0}")]
DiscordError(String),
} }
impl actix_web::ResponseError for ApiError { impl actix_web::ResponseError for ApiError {
@ -272,6 +274,9 @@ impl actix_web::ResponseError for ApiError {
ApiError::Payments(..) => { ApiError::Payments(..) => {
actix_web::http::StatusCode::FAILED_DEPENDENCY actix_web::http::StatusCode::FAILED_DEPENDENCY
} }
ApiError::DiscordError(..) => {
actix_web::http::StatusCode::FAILED_DEPENDENCY
}
} }
} }
@ -294,6 +299,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::Analytics(..) => "analytics_error", ApiError::Analytics(..) => "analytics_error",
ApiError::Crypto(..) => "crypto_error", ApiError::Crypto(..) => "crypto_error",
ApiError::Payments(..) => "payments_error", ApiError::Payments(..) => "payments_error",
ApiError::DiscordError(..) => "discord_error",
}, },
description: &self.to_string(), description: &self.to_string(),
}, },

View File

@ -277,6 +277,7 @@ pub async fn project_create(
&***file_host, &***file_host,
&flame_anvil_queue, &flame_anvil_queue,
&mut uploaded_files, &mut uploaded_files,
&*client,
) )
.await; .await;
@ -334,6 +335,7 @@ pub async fn project_create_inner(
file_host: &dyn FileHost, file_host: &dyn FileHost,
flame_anvil_queue: &Mutex<FlameAnvilQueue>, flame_anvil_queue: &Mutex<FlameAnvilQueue>,
uploaded_files: &mut Vec<UploadedFile>, uploaded_files: &mut Vec<UploadedFile>,
pool: &PgPool,
) -> Result<HttpResponse, CreateError> { ) -> Result<HttpResponse, CreateError> {
// The base URL for files uploaded to backblaze // The base URL for files uploaded to backblaze
let cdn_url = dotenvy::var("CDN_URL")?; let cdn_url = dotenvy::var("CDN_URL")?;
@ -817,7 +819,8 @@ pub async fn project_create_inner(
if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK") if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK")
{ {
crate::util::webhook::send_discord_webhook( crate::util::webhook::send_discord_webhook(
response.clone(), response.id,
pool,
webhook_url, webhook_url,
) )
.await .await

View File

@ -499,7 +499,8 @@ pub async fn project_edit(
dotenvy::var("MODERATION_DISCORD_WEBHOOK") dotenvy::var("MODERATION_DISCORD_WEBHOOK")
{ {
crate::util::webhook::send_discord_webhook( crate::util::webhook::send_discord_webhook(
Project::from(project_item.clone()), project_item.inner.id.into(),
&*pool,
webhook_url, webhook_url,
) )
.await .await
@ -507,7 +508,9 @@ pub async fn project_edit(
} }
} }
if status.is_approved() { if status.is_approved()
&& !project_item.inner.status.is_approved()
{
sqlx::query!( sqlx::query!(
" "
UPDATE mods UPDATE mods
@ -520,6 +523,31 @@ pub async fn project_edit(
.await?; .await?;
} }
if status.is_searchable() && !project_item.inner.webhook_sent {
if let Ok(webhook_url) =
dotenvy::var("PUBLIC_DISCORD_WEBHOOK")
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&*pool,
webhook_url,
)
.await
.ok();
sqlx::query!(
"
UPDATE mods
SET webhook_sent = TRUE
WHERE id = $1
",
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
}
sqlx::query!( sqlx::query!(
" "
UPDATE mods UPDATE mods

View File

@ -231,8 +231,8 @@ pub async fn game_version_list(
.into_iter() .into_iter()
.map(|x| GameVersionQueryData { .map(|x| GameVersionQueryData {
version: x.version, version: x.version,
version_type: x.version_type, version_type: x.type_,
date: x.date, date: x.created,
major: x.major, major: x.major,
}) })
.collect(); .collect();

View File

@ -133,6 +133,7 @@ pub async fn mod_create(
&***file_host, &***file_host,
&flame_anvil_queue, &flame_anvil_queue,
&mut uploaded_files, &mut uploaded_files,
&*client,
) )
.await; .await;

View File

@ -29,6 +29,10 @@ use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use validator::Validate; use validator::Validate;
fn default_requested_status() -> VersionStatus {
VersionStatus::Listed
}
#[derive(Serialize, Deserialize, Validate, Clone)] #[derive(Serialize, Deserialize, Validate, Clone)]
pub struct InitialVersionData { pub struct InitialVersionData {
#[serde(alias = "mod_id")] #[serde(alias = "mod_id")]
@ -59,6 +63,7 @@ pub struct InitialVersionData {
pub loaders: Vec<Loader>, pub loaders: Vec<Loader>,
pub featured: bool, pub featured: bool,
pub primary_file: Option<String>, pub primary_file: Option<String>,
#[serde(default = "default_requested_status")]
pub status: VersionStatus, pub status: VersionStatus,
} }

View File

@ -1,16 +1,30 @@
use crate::models::projects::Project; use crate::database::models::categories::GameVersion;
use crate::models::projects::ProjectId;
use crate::routes::ApiError;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool;
use std::usize;
#[derive(Serialize)] #[derive(Serialize)]
struct DiscordEmbed { struct DiscordEmbed {
pub author: Option<DiscordEmbedAuthor>,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub url: String, pub url: String,
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
pub color: u32, pub color: u32,
pub fields: Vec<DiscordEmbedField>, pub fields: Vec<DiscordEmbedField>,
pub image: DiscordEmbedImage, pub thumbnail: DiscordEmbedThumbnail,
pub image: Option<DiscordEmbedImage>,
pub footer: Option<DiscordEmbedFooter>,
}
#[derive(Serialize)]
struct DiscordEmbedAuthor {
pub name: String,
pub url: Option<String>,
pub icon_url: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -25,83 +39,344 @@ struct DiscordEmbedImage {
pub url: Option<String>, pub url: Option<String>,
} }
#[derive(Serialize)]
struct DiscordEmbedThumbnail {
pub url: Option<String>,
}
#[derive(Serialize)]
struct DiscordEmbedFooter {
pub text: String,
pub icon_url: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct DiscordWebhook { struct DiscordWebhook {
pub avatar_url: Option<String>,
pub username: Option<String>,
pub embeds: Vec<DiscordEmbed>, pub embeds: Vec<DiscordEmbed>,
} }
pub async fn send_discord_webhook( pub async fn send_discord_webhook(
project: Project, project_id: ProjectId,
pool: &PgPool,
webhook_url: String, webhook_url: String,
) -> Result<(), reqwest::Error> { ) -> Result<(), ApiError> {
let mut fields = vec![ let row =
DiscordEmbedField { sqlx::query!(
name: "id", "
value: project.id.to_string(), SELECT m.id id, m.title title, m.description description,
inline: true, m.icon_url icon_url, m.slug slug, cs.name client_side_type, ss.name server_side_type,
}, pt.name project_type, u.username username, u.avatar_url avatar_url,
DiscordEmbedField { ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,
name: "project_type", ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,
value: project.project_type.clone(), JSONB_AGG(DISTINCT TO_JSONB(gv)) filter (where gv.version is not null) versions,
inline: true, JSONB_AGG(DISTINCT TO_JSONB(agv)) filter (where gv.version is not null) all_game_versions,
}, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null) gallery
DiscordEmbedField { FROM mods m
name: "client_side", LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE
value: project.client_side.to_string(), LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id
inline: true, LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ANY($2)
}, LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id
DiscordEmbedField { LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id
name: "server_side", LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id
value: project.server_side.to_string(), LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id
inline: true, LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id
}, LEFT OUTER JOIN game_versions agv ON 1=1
]; INNER JOIN project_types pt ON pt.id = m.project_type
INNER JOIN side_types cs ON m.client_side = cs.id
if !project.categories.is_empty() { INNER JOIN side_types ss ON m.server_side = ss.id
fields.push(DiscordEmbedField { INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE
name: "categories", INNER JOIN users u ON tm.user_id = u.id
value: project.categories.join(", "), WHERE m.id = $1
inline: true, GROUP BY m.id, cs.id, ss.id, pt.id, u.id;
}); ",
} project_id.0 as i64,
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
if let Some(ref slug) = project.slug { crate::models::teams::OWNER_ROLE,
fields.push(DiscordEmbedField { )
name: "slug", .fetch_optional(&*pool)
value: slug.clone(),
inline: true,
});
}
let embed = DiscordEmbed {
url: format!(
"{}/{}/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
project.project_type,
project
.clone()
.slug
.unwrap_or_else(|| project.id.to_string())
),
title: project.title,
description: project.description,
timestamp: project.published,
color: 0x1bd96a,
fields,
image: DiscordEmbedImage {
url: project.icon_url,
},
};
let client = reqwest::Client::new();
client
.post(&webhook_url)
.json(&DiscordWebhook {
embeds: vec![embed],
})
.send()
.await?; .await?;
if let Some(project) = row {
let mut fields = vec![];
let categories = project.categories.unwrap_or_default();
let loaders = project.loaders.unwrap_or_default();
let versions: Vec<GameVersion> =
serde_json::from_value(project.versions.unwrap_or_default())
.map_err(|err| {
ApiError::DiscordError(
"Error while sending projects webhook".to_string(),
)
})?;
let all_game_versions: Vec<GameVersion> = serde_json::from_value(
project.all_game_versions.unwrap_or_default(),
)
.map_err(|err| {
ApiError::DiscordError(
"Error while sending projects webhook".to_string(),
)
})?;
if !categories.is_empty() {
fields.push(DiscordEmbedField {
name: "Categories",
value: categories
.into_iter()
.map(|mut x| format!("{}{x}", x.remove(0).to_uppercase()))
.collect::<Vec<_>>()
.join("\n"),
inline: true,
});
}
if !loaders.is_empty() {
let mut formatted_loaders: String = String::new();
for loader in loaders {
let emoji_id: i64 = match &*loader {
"bukkit" => 1049793345481883689,
"bungeecord" => 1049793347067314220,
"fabric" => 1049793348719890532,
"forge" => 1049793350498275358,
"liteloader" => 1049793351630733333,
"minecraft" => 1049793352964526100,
"modloader" => 1049793353962762382,
"paper" => 1049793355598540810,
"purpur" => 1049793357351751772,
"quilt" => 1049793857681887342,
"rift" => 1049793359373414502,
"spigot" => 1049793413886779413,
"sponge" => 1049793416969605231,
"velocity" => 1049793419108700170,
"waterfall" => 1049793420937412638,
_ => 1049805243866681424,
};
let mut x = loader.clone();
formatted_loaders.push_str(&format!(
"<:{loader}:{emoji_id}> {}{x}\n",
x.remove(0).to_uppercase()
));
}
fields.push(DiscordEmbedField {
name: "Loaders",
value: formatted_loaders,
inline: true,
});
}
if !versions.is_empty() {
let mut formatted_game_versions: String =
get_gv_range(versions, all_game_versions);
fields.push(DiscordEmbedField {
name: "Versions",
value: formatted_game_versions,
inline: true,
});
}
let embed = DiscordEmbed {
author: Some(DiscordEmbedAuthor {
name: project.username.clone(),
url: Some(format!(
"{}/user/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
project.username
)),
icon_url: project.avatar_url,
}),
url: format!(
"{}/{}/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
project.project_type,
project.slug.unwrap_or_else(|| project_id.to_string())
),
title: project.title,
description: project.description,
timestamp: Utc::now(),
color: 0x1bd96a,
fields,
thumbnail: DiscordEmbedThumbnail {
url: project.icon_url,
},
image: project.gallery.unwrap_or_default().first().map(|x| {
DiscordEmbedImage {
url: Some(x.to_string()),
}
}),
footer: Some(DiscordEmbedFooter {
text: "Modrinth".to_string(),
icon_url: Some(
"https://cdn-raw.modrinth.com/modrinth-new.png".to_string(),
),
}),
};
let client = reqwest::Client::new();
client
.post(&webhook_url)
.json(&DiscordWebhook {
avatar_url: Some(
"https://cdn.modrinth.com/Modrinth_Dark_Logo.png"
.to_string(),
),
username: Some("Modrinth Release".to_string()),
embeds: vec![embed],
})
.send()
.await
.map_err(|err| {
ApiError::DiscordError(
"Error while sending projects webhook".to_string(),
)
})?;
}
Ok(()) Ok(())
} }
fn get_gv_range(
mut game_versions: Vec<GameVersion>,
mut all_game_versions: Vec<GameVersion>,
) -> String {
// both -> least to greatest
game_versions.sort_by(|a, b| a.created.cmp(&b.created));
all_game_versions.sort_by(|a, b| a.created.cmp(&b.created));
let all_releases = all_game_versions
.iter()
.filter(|x| &*x.type_ == "release")
.cloned()
.collect::<Vec<_>>();
let mut intervals = Vec::new();
let mut current_interval = 0;
const MAX_VALUE: usize = 1000000;
for i in 0..game_versions.len() {
let current_version = &*game_versions[i].version;
let index = all_game_versions
.iter()
.position(|x| &*x.version == current_version)
.unwrap_or(MAX_VALUE);
let release_index = all_releases
.iter()
.position(|x| &*x.version == current_version)
.unwrap_or(MAX_VALUE);
if i == 0 {
intervals.push(vec![vec![i, index, release_index]])
} else {
let interval_base = &intervals[current_interval];
if ((index as i32)
- (interval_base[interval_base.len() - 1][1] as i32)
== 1
|| (release_index as i32)
- (interval_base[interval_base.len() - 1][2] as i32)
== 1)
&& (all_game_versions[interval_base[0][1]].type_ == "release"
|| all_game_versions[index].type_ != "release")
{
if intervals[current_interval].get(1).is_some() {
intervals[current_interval][1] =
vec![i, index, release_index];
} else {
intervals[current_interval]
.insert(1, vec![i, index, release_index]);
}
} else {
current_interval += 1;
intervals.push(vec![vec![i, index, release_index]]);
}
}
}
let mut new_intervals = Vec::new();
for interval in intervals {
if interval.len() == 2
&& interval[0][2] != MAX_VALUE
&& interval[1][2] == MAX_VALUE
{
let mut last_snapshot: Option<usize> = None;
for j in ((interval[0][1] + 1)..=interval[1][1]).rev() {
if all_game_versions[j].type_ == "release" {
new_intervals.push(vec![
interval[0].clone(),
vec![
game_versions
.iter()
.position(|x| {
x.version == all_game_versions[j].version
})
.unwrap_or(MAX_VALUE),
j,
all_releases
.iter()
.position(|x| {
x.version == all_game_versions[j].version
})
.unwrap_or(MAX_VALUE),
],
]);
if let Some(last_snapshot) = last_snapshot {
if last_snapshot != j + 1 {
new_intervals.push(vec![
vec![
game_versions
.iter()
.position(|x| {
x.version
== all_game_versions
[last_snapshot]
.version
})
.unwrap_or(MAX_VALUE),
last_snapshot,
MAX_VALUE,
],
interval[1].clone(),
])
}
} else {
new_intervals.push(vec![interval[1].clone()])
}
break;
} else {
last_snapshot = Some(j);
}
}
} else {
new_intervals.push(interval);
}
}
let mut output = Vec::new();
for interval in new_intervals {
if interval.len() == 2 {
output.push(format!(
"{}—{}",
&game_versions[interval[0][0]].version,
&game_versions[interval[1][0]].version
))
} else {
output.push(game_versions[interval[0][0]].version.clone())
}
}
output.join("\n")
}

View File

@ -151,7 +151,7 @@ fn game_version_supported(
all_game_versions all_game_versions
.iter() .iter()
.find(|y| y.version == x.0) .find(|y| y.version == x.0)
.map(|x| x.date > date) .map(|x| x.created > date)
.unwrap_or(false) .unwrap_or(false)
}) })
} }
@ -160,7 +160,7 @@ fn game_version_supported(
all_game_versions all_game_versions
.iter() .iter()
.find(|y| y.version == x.0) .find(|y| y.version == x.0)
.map(|x| x.date > before && x.date < after) .map(|x| x.created > before && x.created < after)
.unwrap_or(false) .unwrap_or(false)
}) })
} }