Files
Modrinth-plus/apps/labrinth/src/util/webhook.rs

679 lines
21 KiB
Rust
Raw Normal View History

use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::ids::ProjectId;
2022-12-06 19:51:03 -07:00
use crate::routes::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
use serde::Serialize;
2022-12-06 19:51:03 -07:00
use sqlx::PgPool;
const PLUGIN_LOADERS: &[&str] = &[
"bukkit",
"spigot",
"paper",
"purpur",
"bungeecord",
"waterfall",
"velocity",
"sponge",
];
struct WebhookMetadata {
pub project_url: String,
pub project_title: String,
pub project_summary: String,
pub display_project_type: String,
pub project_icon_url: Option<String>,
pub color: Option<u32>,
pub author: Option<WebhookAuthor>,
pub categories_formatted: Vec<String>,
pub loaders_formatted: Vec<String>,
pub versions_formatted: Vec<String>,
pub gallery_image: Option<String>,
}
struct WebhookAuthor {
pub name: String,
pub url: String,
pub icon_url: Option<String>,
}
async fn get_webhook_metadata(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
) -> Result<Option<WebhookMetadata>, ApiError> {
let project = crate::database::models::project_item::DBProject::get_id(
2024-10-18 16:07:35 -07:00
project_id.into(),
pool,
redis,
)
.await?;
if let Some(mut project) = project {
let mut owner = None;
if let Some(organization_id) = project.inner.organization_id {
let organization = crate::database::models::organization_item::DBOrganization::get_id(
organization_id,
pool,
redis,
)
.await?;
if let Some(organization) = organization {
owner = Some(WebhookAuthor {
name: organization.name,
url: format!(
"{}/organization/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
to_base62(organization.id.0 as u64)
),
icon_url: organization.icon_url,
});
}
} else {
let team = crate::database::models::team_item::DBTeamMember::get_from_team_full(
project.inner.team_id,
pool,
redis,
)
.await?;
if let Some(member) = team.into_iter().find(|x| x.is_owner) {
let user = crate::database::models::user_item::DBUser::get_id(
2024-10-18 16:07:35 -07:00
member.user_id,
pool,
redis,
)
.await?;
if let Some(user) = user {
owner = Some(WebhookAuthor {
url: format!(
"{}/user/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
to_base62(user.id.0 as u64)
),
name: user.username,
icon_url: user.avatar_url,
});
}
}
};
2024-10-18 16:07:35 -07:00
let all_game_versions =
MinecraftGameVersion::list(None, None, pool, redis).await?;
let versions = project
.aggregate_version_fields
.clone()
.into_iter()
2024-10-18 16:07:35 -07:00
.find_map(|vf| {
MinecraftGameVersion::try_from_version_field(&vf).ok()
})
.unwrap_or_default();
let formatted_game_versions = get_gv_range(versions, all_game_versions);
let mut project_type = project.project_types.pop().unwrap_or_default(); // TODO: Should this grab a not-first?
if project
.inner
.loaders
.iter()
.all(|x| PLUGIN_LOADERS.contains(&&**x))
{
project_type = "plugin".to_string();
} else if project.inner.loaders.iter().any(|x| x == "datapack") {
project_type = "datapack".to_string();
}
let mut display_project_type = match &*project_type {
"datapack" => "data pack",
"resourcepack" => "resource pack",
_ => &*project_type,
}
.to_string();
Ok(Some(WebhookMetadata {
project_url: format!(
"{}/{}/{}",
dotenvy::var("SITE_URL").unwrap_or_default(),
project_type,
to_base62(project.inner.id.0 as u64)
),
project_title: project.inner.name,
project_summary: project.inner.summary,
display_project_type: format!(
"{}{display_project_type}",
display_project_type.remove(0).to_uppercase()
),
project_icon_url: project.inner.icon_url,
color: project.inner.color,
author: owner,
categories_formatted: project
.categories
.into_iter()
.map(format_category_or_loader)
.collect(),
loaders_formatted: project
.inner
.loaders
.into_iter()
.map(format_category_or_loader)
.collect(),
versions_formatted: formatted_game_versions,
gallery_image: project
.gallery_items
.into_iter()
.find(|x| x.featured)
.map(|x| x.image_url),
}))
} else {
Ok(None)
}
}
pub enum PayoutSourceAlertType {
UnderThreshold {
source: String,
threshold: u64,
current_balance: u64,
},
CheckFailure {
source: String,
display_error: String,
},
}
impl PayoutSourceAlertType {
pub fn message(&self) -> String {
match self {
PayoutSourceAlertType::UnderThreshold {
source,
threshold,
current_balance,
} => format!(
"\u{1f6a8} *Payout Source Alert*\n\nPayout source '{source}' has an available balance under the ${threshold} threshold.\nBalance: ${current_balance}."
),
PayoutSourceAlertType::CheckFailure {
source,
display_error,
} => format!(
"\u{1f6a8} *Payout Source Alert*\n\nFAILED TO CHECK payout source '{source}' balance.\nError: {display_error}"
),
}
}
}
pub async fn send_slack_payout_source_alert_webhook(
alert: PayoutSourceAlertType,
webhook_url: &str,
) -> Result<(), ApiError> {
let client = reqwest::Client::new();
client
.post(webhook_url)
.json(&serde_json::json!({
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": alert.message()
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": format!("via labrinth • <!date^{}^{{date_short_pretty}} at {{time}}|Unknown date>", Utc::now().timestamp())
}
]
}
],
}))
.send()
.await
.map_err(|_| {
ApiError::Slack("Error while sending projects webhook".to_string())
})?;
Ok(())
}
pub async fn send_slack_project_webhook(
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: String,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
if let Some(metadata) = metadata {
let mut blocks = vec![];
if let Some(message) = message {
blocks.push(serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": message,
}
}));
}
if let Some(ref author) = metadata.author {
let mut elements = vec![];
if let Some(ref icon_url) = author.icon_url {
elements.push(serde_json::json!({
"type": "image",
"image_url": icon_url,
"alt_text": "Author"
}));
}
elements.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("<{}|{}>", author.url, author.name)
}));
blocks.push(serde_json::json!({
"type": "context",
"elements": elements
}));
}
let mut project_block = serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(
"*<{}|{}>*\n\n{}\n\n*Categories:* {}\n\n*Loaders:* {}\n\n*Versions:* {}",
metadata.project_url,
metadata.project_title,
metadata.project_summary,
metadata.categories_formatted.join(", "),
metadata.loaders_formatted.join(", "),
metadata.versions_formatted.join(", ")
)
}
});
if let Some(icon_url) = metadata.project_icon_url
&& let Some(project_block) = project_block.as_object_mut()
{
project_block.insert(
"accessory".to_string(),
serde_json::json!({
"type": "image",
"image_url": icon_url,
"alt_text": metadata.project_title
}),
);
}
blocks.push(project_block);
if let Some(gallery_image) = metadata.gallery_image {
blocks.push(serde_json::json!({
"type": "image",
"image_url": gallery_image,
"alt_text": metadata.project_title
}));
}
blocks.push(
serde_json::json!({
"type": "context",
"elements": [
{
"type": "image",
"image_url": "https://cdn-raw.modrinth.com/modrinth-new.png",
"alt_text": "Author"
},
{
"type": "mrkdwn",
"text": format!("{} on Modrinth • <!date^{}^{{date_short_pretty}} at {{time}}|Unknown date>", metadata.display_project_type, Utc::now().timestamp())
}
]
})
);
let client = reqwest::Client::new();
client
.post(&webhook_url)
.json(&serde_json::json!({
"blocks": blocks,
}))
.send()
.await
2024-10-18 16:07:35 -07:00
.map_err(|_| {
ApiError::Slack(
2024-10-18 16:07:35 -07:00
"Error while sending projects webhook".to_string(),
)
})?;
}
Ok(())
}
#[derive(Serialize)]
struct DiscordEmbed {
2022-12-06 19:51:03 -07:00
pub author: Option<DiscordEmbedAuthor>,
pub title: String,
pub description: String,
pub url: String,
pub timestamp: DateTime<Utc>,
pub color: u32,
pub fields: Vec<DiscordEmbedField>,
2022-12-06 19:51:03 -07:00
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)]
struct DiscordEmbedField {
pub name: &'static str,
pub value: String,
pub inline: bool,
}
#[derive(Serialize)]
struct DiscordEmbedImage {
pub url: Option<String>,
}
2022-12-06 19:51:03 -07:00
#[derive(Serialize)]
struct DiscordEmbedThumbnail {
pub url: Option<String>,
}
#[derive(Serialize)]
struct DiscordEmbedFooter {
pub text: String,
pub icon_url: Option<String>,
}
#[derive(Serialize)]
struct DiscordWebhook {
2022-12-06 19:51:03 -07:00
pub avatar_url: Option<String>,
pub username: Option<String>,
pub embeds: Vec<DiscordEmbed>,
pub content: Option<String>,
}
pub async fn send_discord_webhook(
2022-12-06 19:51:03 -07:00
project_id: ProjectId,
pool: &PgPool,
redis: &RedisPool,
webhook_url: String,
message: Option<String>,
2022-12-06 19:51:03 -07:00
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
2022-12-06 19:51:03 -07:00
if let Some(project) = metadata {
let mut fields = vec![];
if !project.categories_formatted.is_empty() {
2022-12-06 19:51:03 -07:00
fields.push(DiscordEmbedField {
name: "Categories",
value: project.categories_formatted.join("\n"),
2022-12-06 19:51:03 -07:00
inline: true,
});
}
if !project.loaders_formatted.is_empty() {
2022-12-06 19:51:03 -07:00
fields.push(DiscordEmbedField {
name: "Loaders",
value: project.loaders_formatted.join("\n"),
2022-12-06 19:51:03 -07:00
inline: true,
});
}
if !project.versions_formatted.is_empty() {
2022-12-06 19:51:03 -07:00
fields.push(DiscordEmbedField {
name: "Versions",
value: project.versions_formatted.join("\n"),
2022-12-06 19:51:03 -07:00
inline: true,
});
}
let embed = DiscordEmbed {
author: project.author.map(|x| DiscordEmbedAuthor {
name: x.name,
url: Some(x.url),
icon_url: x.icon_url,
2022-12-06 19:51:03 -07:00
}),
url: project.project_url,
title: project.project_title, // Do not change DiscordEmbed
description: project.project_summary,
2022-12-06 19:51:03 -07:00
timestamp: Utc::now(),
color: project.color.unwrap_or(0x1bd96a),
2022-12-06 19:51:03 -07:00
fields,
thumbnail: DiscordEmbedThumbnail {
url: project.project_icon_url,
2022-12-06 19:51:03 -07:00
},
image: project
.gallery_image
.map(|x| DiscordEmbedImage { url: Some(x) }),
2022-12-06 19:51:03 -07:00
footer: Some(DiscordEmbedFooter {
text: format!("{} on Modrinth", project.display_project_type),
2024-10-18 16:07:35 -07:00
icon_url: Some(
"https://cdn-raw.modrinth.com/modrinth-new.png".to_string(),
),
2022-12-06 19:51:03 -07:00
}),
};
let client = reqwest::Client::new();
client
.post(&webhook_url)
.json(&DiscordWebhook {
2024-10-18 16:07:35 -07:00
avatar_url: Some(
"https://cdn.modrinth.com/Modrinth_Dark_Logo.png"
.to_string(),
),
2022-12-06 19:51:03 -07:00
username: Some("Modrinth Release".to_string()),
embeds: vec![embed],
content: message,
2022-12-06 19:51:03 -07:00
})
.send()
.await
2024-10-18 16:07:35 -07:00
.map_err(|_| {
ApiError::Discord(
"Error while sending projects webhook".to_string(),
)
})?;
}
2022-12-06 19:51:03 -07:00
Ok(())
}
fn get_gv_range(
mut game_versions: Vec<MinecraftGameVersion>,
mut all_game_versions: Vec<MinecraftGameVersion>,
) -> Vec<String> {
2022-12-06 19:51:03 -07:00
// both -> least to greatest
game_versions.sort_by(|a, b| a.created.cmp(&b.created));
game_versions.dedup_by(|a, b| a.version == b.version);
2022-12-06 19:51:03 -07:00
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, current_version) in game_versions.iter().enumerate() {
let current_version = &current_version.version;
2022-12-06 19:51:03 -07:00
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];
2024-10-18 16:07:35 -07:00
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)
2022-12-06 19:51:03 -07:00
&& (all_game_versions[interval_base[0][1]].type_ == "release"
|| all_game_versions[index].type_ != "release")
{
if intervals[current_interval].get(1).is_some() {
2024-10-18 16:07:35 -07:00
intervals[current_interval][1] =
vec![i, index, release_index];
2022-12-06 19:51:03 -07:00
} else {
2024-10-18 16:07:35 -07:00
intervals[current_interval]
.insert(1, vec![i, index, release_index]);
2022-12-06 19:51:03 -07:00
}
} else {
current_interval += 1;
intervals.push(vec![vec![i, index, release_index]]);
}
}
}
2022-12-06 19:51:03 -07:00
let mut new_intervals = Vec::new();
2022-12-06 19:51:03 -07:00
for interval in intervals {
2024-10-18 16:07:35 -07:00
if interval.len() == 2
&& interval[0][2] != MAX_VALUE
&& interval[1][2] == MAX_VALUE
{
2022-12-06 19:51:03 -07:00
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()
2024-10-18 16:07:35 -07:00
.position(|x| {
x.version == all_game_versions[j].version
})
2022-12-06 19:51:03 -07:00
.unwrap_or(MAX_VALUE),
j,
all_releases
.iter()
2024-10-18 16:07:35 -07:00
.position(|x| {
x.version == all_game_versions[j].version
})
2022-12-06 19:51:03 -07:00
.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| {
2024-10-18 16:07:35 -07:00
x.version
== all_game_versions
[last_snapshot]
.version
2022-12-06 19:51:03 -07:00
})
.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!(
"{}–{}",
2024-10-18 16:07:35 -07:00
&game_versions[interval[0][0]].version,
&game_versions[interval[1][0]].version
2022-12-06 19:51:03 -07:00
))
} else {
output.push(game_versions[interval[0][0]].version.clone())
}
}
output
}
// Converted from knossos
// See: packages/utils/utils.ts
// https://github.com/modrinth/code/blob/47af459f24e541a844b42b1c8427af6a7b86381e/packages/utils/utils.ts#L147-L196
fn format_category_or_loader(mut x: String) -> String {
match &*x {
"modloader" => "Risugami's ModLoader".to_string(),
"bungeecord" => "BungeeCord".to_string(),
"liteloader" => "LiteLoader".to_string(),
"neoforge" => "NeoForge".to_string(),
"game-mechanics" => "Game Mechanics".to_string(),
"worldgen" => "World Generation".to_string(),
"core-shaders" => "Core Shaders".to_string(),
"gui" => "GUI".to_string(),
"8x-" => "8x or lower".to_string(),
"512x+" => "512x or higher".to_string(),
"kitchen-sink" => "Kitchen Sink".to_string(),
"path-tracing" => "Path Tracing".to_string(),
"pbr" => "PBR".to_string(),
"datapack" => "Data Pack".to_string(),
"colored-lighting" => "Colored Lighting".to_string(),
"optifine" => "OptiFine".to_string(),
"bta-babric" => "BTA (Babric)".to_string(),
"legacy-fabric" => "Legacy Fabric".to_string(),
"java-agent" => "Java Agent".to_string(),
"nilloader" => "NilLoader".to_string(),
"mrpack" => "Modpack".to_string(),
"minecraft" => "Resource Pack".to_string(),
"vanilla" => "Vanilla Shader".to_string(),
_ => format!("{}{x}", x.remove(0).to_uppercase()),
}
}