1
0
Files
AstralRinth/apps/labrinth/src/util/webhook.rs
Josiah Glosson 9e527ff141 Labrinth ID cleanup (#3681)
* Put all ID types in the labrinth::models::ids, and reduce code duplication with them

* Rewrite labrinth::database::models::ids and rename most DB interface ID structs to be prefixed with DB

* Run sqlx prepare

---------

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-05-22 08:34:36 +00:00

622 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::ids::ProjectId;
use crate::routes::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
use serde::Serialize;
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,
emoji: bool,
) -> Result<Option<WebhookMetadata>, ApiError> {
let project = crate::database::models::project_item::Project::get_id(
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::Organization::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::TeamMember::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::User::get_id(
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,
});
}
}
};
let all_game_versions =
MinecraftGameVersion::list(None, None, pool, redis).await?;
let versions = project
.aggregate_version_fields
.clone()
.into_iter()
.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(|mut x| format!("{}{x}", x.remove(0).to_uppercase()))
.collect::<Vec<_>>(),
loaders_formatted: project
.inner
.loaders
.into_iter()
.map(|loader| {
let mut x = if &*loader == "datapack" {
"Data Pack".to_string()
} else if &*loader == "mrpack" {
"Modpack".to_string()
} else {
loader.clone()
};
if emoji {
let emoji_id: i64 = match &*loader {
"bukkit" => 1049793345481883689,
"bungeecord" => 1049793347067314220,
"canvas" => 1107352170656968795,
"datapack" => 1057895494652788866,
"fabric" => 1049793348719890532,
"folia" => 1107348745571537018,
"forge" => 1049793350498275358,
"iris" => 1107352171743281173,
"liteloader" => 1049793351630733333,
"minecraft" => 1049793352964526100,
"modloader" => 1049793353962762382,
"neoforge" => 1140437823783190679,
"optifine" => 1107352174415052901,
"paper" => 1049793355598540810,
"purpur" => 1140436034505674762,
"quilt" => 1049793857681887342,
"rift" => 1049793359373414502,
"spigot" => 1049793413886779413,
"sponge" => 1049793416969605231,
"vanilla" => 1107350794178678855,
"velocity" => 1049793419108700170,
"waterfall" => 1049793420937412638,
_ => 1049805243866681424,
};
format!(
"<:{loader}:{emoji_id}> {}{x}",
x.remove(0).to_uppercase()
)
} else {
format!("{}{x}", x.remove(0).to_uppercase())
}
})
.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 async fn send_slack_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, false).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 {
if 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
.map_err(|_| {
ApiError::Discord(
"Error while sending projects webhook".to_string(),
)
})?;
}
Ok(())
}
#[derive(Serialize)]
struct DiscordEmbed {
pub author: Option<DiscordEmbedAuthor>,
pub title: String,
pub description: String,
pub url: String,
pub timestamp: DateTime<Utc>,
pub color: u32,
pub fields: Vec<DiscordEmbedField>,
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>,
}
#[derive(Serialize)]
struct DiscordEmbedThumbnail {
pub url: Option<String>,
}
#[derive(Serialize)]
struct DiscordEmbedFooter {
pub text: String,
pub icon_url: Option<String>,
}
#[derive(Serialize)]
struct DiscordWebhook {
pub avatar_url: Option<String>,
pub username: Option<String>,
pub embeds: Vec<DiscordEmbed>,
pub content: Option<String>,
}
pub async fn send_discord_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, true).await?;
if let Some(project) = metadata {
let mut fields = vec![];
if !project.categories_formatted.is_empty() {
fields.push(DiscordEmbedField {
name: "Categories",
value: project.categories_formatted.join("\n"),
inline: true,
});
}
if !project.loaders_formatted.is_empty() {
fields.push(DiscordEmbedField {
name: "Loaders",
value: project.loaders_formatted.join("\n"),
inline: true,
});
}
if !project.versions_formatted.is_empty() {
fields.push(DiscordEmbedField {
name: "Versions",
value: project.versions_formatted.join("\n"),
inline: true,
});
}
let embed = DiscordEmbed {
author: project.author.map(|x| DiscordEmbedAuthor {
name: x.name,
url: Some(x.url),
icon_url: x.icon_url,
}),
url: project.project_url,
title: project.project_title, // Do not change DiscordEmbed
description: project.project_summary,
timestamp: Utc::now(),
color: project.color.unwrap_or(0x1bd96a),
fields,
thumbnail: DiscordEmbedThumbnail {
url: project.project_icon_url,
},
image: project
.gallery_image
.map(|x| DiscordEmbedImage { url: Some(x) }),
footer: Some(DiscordEmbedFooter {
text: format!("{} on Modrinth", project.display_project_type),
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],
content: message,
})
.send()
.await
.map_err(|_| {
ApiError::Discord(
"Error while sending projects webhook".to_string(),
)
})?;
}
Ok(())
}
fn get_gv_range(
mut game_versions: Vec<MinecraftGameVersion>,
mut all_game_versions: Vec<MinecraftGameVersion>,
) -> Vec<String> {
// 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);
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;
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
}