From 8976273410d70d97b6b1172a00fbb467d4352bae Mon Sep 17 00:00:00 2001 From: Zomatree Date: Mon, 16 Feb 2026 22:28:51 +0000 Subject: [PATCH] feat: initial work on discord template importer --- Cargo.lock | 14 + .../src/models/server_members/model.rs | 5 +- crates/core/discord/Cargo.toml | 27 ++ crates/core/discord/src/lib.rs | 2 + crates/core/discord/src/template.rs | 275 ++++++++++++++++++ crates/core/discord/src/types.rs | 81 ++++++ crates/core/models/src/v0/servers.rs | 3 + crates/delta/Cargo.toml | 1 + .../delta/src/routes/servers/server_create.rs | 12 +- 9 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 crates/core/discord/Cargo.toml create mode 100644 crates/core/discord/src/lib.rs create mode 100644 crates/core/discord/src/template.rs create mode 100644 crates/core/discord/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 827b1645..4ec04067 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6983,6 +6983,7 @@ dependencies = [ "reqwest 0.11.27", "revolt-config", "revolt-database", + "revolt-discord", "revolt-models", "revolt-permissions", "revolt-presence", @@ -7003,6 +7004,19 @@ dependencies = [ "vergen", ] +[[package]] +name = "revolt-discord" +version = "0.11.3" +dependencies = [ + "reqwest 0.12.23", + "revolt-database", + "revolt-models", + "revolt-permissions", + "revolt-result", + "serde", + "serde_json", +] + [[package]] name = "revolt-files" version = "0.11.3" diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index 0062a270..816e4b52 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use crate::{ - events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, - Database, File, Server, SystemMessage, User, + events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File, + Server, SystemMessage, User, }; fn default_true() -> bool { @@ -332,6 +332,7 @@ mod tests { name: "Server".to_string(), description: None, nsfw: None, + discord_template_id: None, }, &owner, false, diff --git a/crates/core/discord/Cargo.toml b/crates/core/discord/Cargo.toml new file mode 100644 index 00000000..873b28f2 --- /dev/null +++ b/crates/core/discord/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "revolt-discord" +version = "0.11.3" +edition = "2021" +license = "MIT" +authors = ["Zomatree "] +description = "Revolt Backend: Discord Compatibility" +repository = "https://github.com/stoatchat/stoatchat" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] + + +[dependencies] +# Core +revolt-result = { version = "0.11.3", path = "../result" } +revolt-database = { version = "0.11.3", path = "../database" } +revolt-permissions = { version = "0.11.3", path = "../permissions" } +revolt-models = { version = "0.11.3", path = "../models" } + +# Serialisation +serde_json = { version = "1" } +serde = { version = "1", features = ["derive"] } + +# Http +reqwest = { version = "0.12", features = ["json"] } diff --git a/crates/core/discord/src/lib.rs b/crates/core/discord/src/lib.rs new file mode 100644 index 00000000..f912bb2d --- /dev/null +++ b/crates/core/discord/src/lib.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod template; \ No newline at end of file diff --git a/crates/core/discord/src/template.rs b/crates/core/discord/src/template.rs new file mode 100644 index 00000000..6f2562ae --- /dev/null +++ b/crates/core/discord/src/template.rs @@ -0,0 +1,275 @@ +use std::collections::HashMap; + +use revolt_models::v0; +use revolt_permissions::OverrideField; + +use crate::types as discord; +use revolt_database::{ + Category, Channel, Database, PartialChannel, PartialRole, PartialServer, Role, Server, + SystemMessageChannels, +}; +use revolt_result::{Result, ToRevoltError}; + +pub async fn fetch_template(template_id: &str) -> Result { + reqwest::get(format!( + "https://discord.com/api/v10/guilds/templates/{template_id}" + )) + .await + .to_internal_error()? + .json() + .await + .to_internal_error() +} + +pub async fn import_template( + db: &Database, + server: &mut Server, + template: discord::GuildTemplate, +) -> Result> { + let mut role_mapping = HashMap::new(); + let mut channel_mapping = HashMap::new(); + let mut categories = HashMap::new(); + + for discord_channel in &template.serialized_source_guild.channels { + if discord_channel.channel_type == discord::ChannelType::Category as u32 { + categories.insert(discord_channel.id, discord_channel.clone()); + }; + } + + let mut role_positions = template.serialized_source_guild.roles + .iter() + .filter(|r| r.id != 0) + .collect::>(); + + role_positions.sort_by(|a, b| b.id.cmp(&a.id)); + + let role_positions = role_positions + .into_iter() + .enumerate() + .map(|(i, r)| (r.id, i as i64)) + .collect::>(); + + for discord_role in template.serialized_source_guild.roles.clone() { + if discord_role.id == 0 { + continue; + }; + + role_mapping.insert( + discord_role.id, + convert_role(db, server, role_positions[&discord_role.id], discord_role).await?, + ); + }; + + for discord_channel in template.serialized_source_guild.channels.clone() { + if discord_channel.channel_type == discord::ChannelType::Category as u32 { + continue; + }; + + channel_mapping.insert( + discord_channel.id, + convert_channel(db, server, &categories, &role_mapping, discord_channel).await?, + ); + } + + convert_server(db, server, &channel_mapping, template.serialized_source_guild).await?; + + Ok(channel_mapping.into_values().collect()) +} + +pub async fn convert_role(db: &Database, server: &mut Server, rank: i64, role: discord::Role) -> Result { + let mut role_payload = Role::create(db, server, role.name).await?; + let mut partial = PartialRole { + rank: Some(rank), + ..Default::default() + }; + + if role.hoist { + partial.hoist = Some(true) + }; + + if let Some(colors) = role.colors { + if colors.primary_color != 0 { + partial.colour = Some(format!("#{:x}", colors.primary_color)) + }; + }; + + if &role.permissions != "0" { + let allow = convert_permissions(&role.permissions); + partial.permissions = Some(OverrideField { a: allow, d: 0 }); + }; + + role_payload + .update(db, &server.id, partial, Vec::new()) + .await?; + + server.roles.insert(role_payload.id.clone(), role_payload.clone()); + + Ok(role_payload) +} + +static PERMISSION_MAPPING: &[(i64, i64)] = &[]; + +pub fn convert_permissions(permissions: &str) -> i64 { + let value = permissions.parse::().unwrap(); + let mut output = 0; + + for (discord_perm, perm) in PERMISSION_MAPPING { + if (value & *discord_perm) == *discord_perm { + output |= *perm + }; + } + + output +} + +pub async fn convert_channel( + db: &Database, + server: &mut Server, + categories: &HashMap, + role_mapping: &HashMap, + channel: discord::Channel, +) -> Result { + let mut channel_payload = Channel::create_server_channel( + db, + server, + v0::DataCreateServerChannel { + channel_type: v0::LegacyServerChannelType::Text, + name: channel.name, + description: channel.topic, + nsfw: channel.nsfw, + voice: if channel.channel_type == discord::ChannelType::Voice as u32 + || channel.channel_type == discord::ChannelType::StageVoice as u32 + { + Some(v0::VoiceInformation { + max_users: channel.user_limit.map(|limit| limit as usize), + }) + } else { + None + }, + }, + true, + ) + .await?; + + let mut partial = PartialChannel::default(); + let mut role_permissions = HashMap::new(); + + if let Some(parent_id) = channel.parent_id { + let category = categories.get(&parent_id).unwrap(); + + for discord_overwrite in category.permission_overwrites.iter().flatten() { + if discord_overwrite.overwrite_type == discord::OverwriteType::Member as u32 { + continue; + }; + + let overwrite = OverrideField { + a: convert_permissions(&discord_overwrite.allow), + d: convert_permissions(&discord_overwrite.deny), + }; + + if discord_overwrite.id == 0 { + partial.default_permissions = Some(overwrite) + } else { + role_permissions.insert( + role_mapping.get(&discord_overwrite.id).unwrap().id.clone(), + overwrite, + ); + }; + } + }; + + for discord_overwrite in channel.permission_overwrites.iter().flatten() { + if discord_overwrite.overwrite_type == discord::OverwriteType::Member as u32 { + continue; + }; + + let overwrite = OverrideField { + a: convert_permissions(&discord_overwrite.allow), + d: convert_permissions(&discord_overwrite.deny), + }; + + if discord_overwrite.id == 0 { + if let Some(default_permissions) = &mut partial.default_permissions { + default_permissions.a |= overwrite.a; + default_permissions.d |= overwrite.d; + } else { + partial.default_permissions = Some(overwrite) + } + } else { + let id = &role_mapping.get(&discord_overwrite.id).unwrap().id; + + if let Some(role_permission) = role_permissions.get_mut(id) { + role_permission.a |= overwrite.a; + role_permission.d |= overwrite.d; + } else { + role_permissions.insert(id.clone(), overwrite); + }; + }; + } + + partial.role_permissions = Some(role_permissions); + + channel_payload.update(db, partial, Vec::new()).await?; + + Ok(channel_payload) +} + +pub async fn convert_server( + db: &Database, + server: &mut Server, + channel_mapping: &HashMap, + guild: discord::Guild, +) -> Result<()> { + let mut partial = PartialServer { + categories: Some( + guild + .channels + .iter() + .filter(|c| c.channel_type == discord::ChannelType::Category as u32) + .map(|category| { + let mut channels = guild + .channels + .iter() + .filter(|c| c.parent_id.as_ref() == Some(&category.id)) + .collect::>(); + + channels.sort_by(|a, b| a.position.cmp(&b.position)); + + Category { + id: category.id.to_string(), + title: category.name.clone(), + channels: channels + .into_iter() + .map(|c| channel_mapping.get(&c.id).unwrap().id().to_string()) + .collect(), + } + }) + .collect(), + ), + ..Default::default() + }; + + // TODO: banner + + if let Some(system_channel_id) = &guild.system_channel_id { + let channel_id = channel_mapping + .get(system_channel_id) + .unwrap() + .id() + .to_string(); + + partial.system_messages = Some(SystemMessageChannels { + user_banned: Some(channel_id.clone()), + user_joined: Some(channel_id.clone()), + user_kicked: Some(channel_id.clone()), + user_left: Some(channel_id), + }); + }; + + let default_role = guild.roles.iter().find(|r| r.id == 0).unwrap(); + partial.default_permissions = Some(convert_permissions(&default_role.permissions)); + + server.update(db, partial, Vec::new()).await?; + + Ok(()) +} diff --git a/crates/core/discord/src/types.rs b/crates/core/discord/src/types.rs new file mode 100644 index 00000000..32556253 --- /dev/null +++ b/crates/core/discord/src/types.rs @@ -0,0 +1,81 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize)] +pub struct GuildTemplate { + pub serialized_source_guild: Guild +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct Guild { + pub name: String, + pub banner: Option, + pub roles: Vec, + pub channels: Vec, + pub system_channel_id: Option, + pub description: Option, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct Role { + pub id: u32, + pub name: String, + pub colors: Option, + pub hoist: bool, + pub icon: Option, + pub position: Option, + pub permissions: String, + pub mentionable: bool +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct RoleColor { + pub primary_color: u64, + pub secondary_color: Option, + pub tertiary_color: Option, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct Channel { + pub id: u32, + #[serde(rename = "type")] + pub channel_type: u32, + pub position: u32, + pub permission_overwrites: Option>, + pub name: String, + pub topic: Option, + pub nsfw: Option, + pub user_limit: Option, + pub parent_id: Option, +} + +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +pub enum ChannelType { + Text = 0, + DM = 1, + Voice = 2, + Group = 3, + Category = 4, + Announcement = 5, + AnnouncementThread = 10, + PublicThread = 11, + PrivateThread = 12, + StageVoice = 13, + Directory = 14, + Forum = 15, + Media = 16, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct Overwrite { + pub id: u32, + #[serde(rename = "type")] + pub overwrite_type: u32, + pub allow: String, + pub deny: String, +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +pub enum OverwriteType { + Role = 0, + Member = 1, +} \ No newline at end of file diff --git a/crates/core/models/src/v0/servers.rs b/crates/core/models/src/v0/servers.rs index 0d330334..e1a60dbf 100644 --- a/crates/core/models/src/v0/servers.rs +++ b/crates/core/models/src/v0/servers.rs @@ -167,6 +167,9 @@ auto_derived!( /// Whether this server is age-restricted #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub nsfw: Option, + /// Discord guild template to import from + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub discord_template_id: Option, } /// Information about new role to create diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index 5c43deb9..a431f133 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -84,6 +84,7 @@ revolt-presence = { path = "../core/presence" } revolt-result = { path = "../core/result", features = ["rocket", "okapi"] } revolt-permissions = { path = "../core/permissions", features = ["schemas"] } revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] } +revolt-discord = { path = "../core/discord" } # voice livekit-api = "0.4.4" diff --git a/crates/delta/src/routes/servers/server_create.rs b/crates/delta/src/routes/servers/server_create.rs index 82f0a8aa..6b3e73fe 100644 --- a/crates/delta/src/routes/servers/server_create.rs +++ b/crates/delta/src/routes/servers/server_create.rs @@ -29,7 +29,17 @@ pub async fn create_server( user.can_acquire_server(db).await?; - let (server, channels) = Server::create(db, data, &user, true).await?; + let (server, channels) = if let Some(discord_template_id) = data.discord_template_id.clone() { + let (mut server, _) = Server::create(db, data, &user, false).await?; + + let template = revolt_discord::template::fetch_template(&discord_template_id).await?; + let channels = revolt_discord::template::import_template(db, &mut server, template).await?; + + (server, channels) + } else { + Server::create(db, data, &user, true).await? + }; + let (_, channels) = Member::create(db, &server, &user, Some(channels)).await?; Ok(Json(v0::CreateServerLegacyResponse {