You've already forked AstralRinth
forked from didirus/AstralRinth
626 lines
20 KiB
Rust
626 lines
20 KiB
Rust
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
|
||
use crate::database::redis::RedisPool;
|
||
use crate::models::ids::base62_impl::to_base62;
|
||
use crate::models::projects::ProjectId;
|
||
use crate::routes::ApiError;
|
||
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(),
|
||
organization.slug
|
||
),
|
||
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(),
|
||
user.username
|
||
),
|
||
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,
|
||
project
|
||
.inner
|
||
.slug
|
||
.clone()
|
||
.unwrap_or_else(|| 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 = ¤t_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
|
||
}
|