diff --git a/crates/core/database/src/models/bots/model.rs b/crates/core/database/src/models/bots/model.rs index 8d7a8d3d..9ed7918c 100644 --- a/crates/core/database/src/models/bots/model.rs +++ b/crates/core/database/src/models/bots/model.rs @@ -18,6 +18,9 @@ auto_derived_partial!( /// Whether the bot is public /// (may be invited by anyone) pub public: bool, + /// The permissions the bot will ask to have upon being invited to a server, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_permissions: Option, /// Whether to enable analytics #[serde(skip_serializing_if = "crate::if_false", default)] @@ -58,6 +61,7 @@ impl Default for Bot { owner: Default::default(), token: Default::default(), public: Default::default(), + default_permissions: Default::default(), analytics: Default::default(), discoverable: Default::default(), interactions_url: Default::default(), diff --git a/crates/core/database/src/models/servers/model.rs b/crates/core/database/src/models/servers/model.rs index 59b15000..b718b864 100644 --- a/crates/core/database/src/models/servers/model.rs +++ b/crates/core/database/src/models/servers/model.rs @@ -89,6 +89,13 @@ auto_derived_partial!( /// Custom icon attachment #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// Id of the bot that owns this role, if it is a managed role + /// + /// Managed roles are created automatically (e.g. via bot invite) and + /// should be hidden from "assign role" UI and cleaned up when the + /// owning bot leaves the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, }, "PartialRole" ); @@ -133,6 +140,7 @@ auto_derived!( pub enum FieldsRole { Colour, Icon, + Owner, } ); @@ -322,6 +330,20 @@ impl Server { Ok(()) } + + /// Delete the managed role owned by the given bot in this server, if one exists. + /// No-op if the bot never had a managed role. + pub async fn cleanup_managed_bot_role(&self, db: &Database, bot_id: &str) -> Result<()> { + if let Some(role) = self + .roles + .values() + .find(|role| role.owner.as_deref() == Some(bot_id)) + { + role.delete(db, &self.id).await?; + } + + Ok(()) + } } impl Role { @@ -335,20 +357,41 @@ impl Role { hoist: Some(self.hoist), rank: Some(self.rank), icon: self.icon, + owner: self.owner, } } /// Create a role pub async fn create(db: &Database, server: &Server, name: String) -> Result { + Self::create_inner(db, server, name, None).await + } + + /// Create a role owned/managed by a bot + pub async fn create_managed( + db: &Database, + server: &Server, + name: String, + owner_bot_id: String, + ) -> Result { + Self::create_inner(db, server, name, Some(owner_bot_id)).await + } + + /// Helper function to avoid code duplication between `create` and `create_managed` + async fn create_inner( + db: &Database, + server: &Server, + name: String, + owner: Option, + ) -> Result { let role = Role { id: Ulid::new().to_string(), name, - // Rank of the new role should be below the lowest role rank: server.roles.len() as i64, colour: None, hoist: false, permissions: Default::default(), icon: None, + owner, }; db.insert_role(&server.id, &role).await?; @@ -359,12 +402,16 @@ impl Role { data: role.clone().into_optional().into(), clear: vec![], } - .p(server.id.clone()) - .await; + .p(server.id.clone()) + .await; Ok(role) } + pub fn is_managed(&self) -> bool { + self.owner.is_some() + } + /// Update server data pub async fn update( &mut self, @@ -399,6 +446,7 @@ impl Role { match field { FieldsRole::Colour => self.colour = None, FieldsRole::Icon => self.icon = None, + FieldsRole::Owner => self.owner = None, } } @@ -415,6 +463,7 @@ impl Role { hoist, rank, (FieldsRole::Icon) icon, + (FieldsRole::Owner) owner, ) ); diff --git a/crates/core/database/src/models/servers/ops/mongodb.rs b/crates/core/database/src/models/servers/ops/mongodb.rs index ef62ca97..8fd0b00a 100644 --- a/crates/core/database/src/models/servers/ops/mongodb.rs +++ b/crates/core/database/src/models/servers/ops/mongodb.rs @@ -184,6 +184,7 @@ impl IntoDocumentPath for FieldsRole { Some(match self { FieldsRole::Colour => "colour", FieldsRole::Icon => "icon", + FieldsRole::Owner => "owner", }) } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index acb0b4c5..09ea14c5 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -28,6 +28,7 @@ impl From for Bot { owner_id: value.owner, token: value.token, public: value.public, + default_permissions: value.default_permissions, analytics: value.analytics, discoverable: value.discoverable, interactions_url: value.interactions_url, @@ -935,6 +936,7 @@ impl From for Role { hoist: value.hoist, rank: value.rank, icon: value.icon.map(|f| f.into()), + owner: value.owner } } } @@ -949,6 +951,7 @@ impl From for crate::Role { hoist: value.hoist, rank: value.rank, icon: value.icon.map(|f| f.into()), + owner: value.owner } } } @@ -963,6 +966,7 @@ impl From for PartialRole { hoist: value.hoist, rank: value.rank, icon: value.icon.map(|f| f.into()), + owner: value.owner } } } @@ -977,6 +981,7 @@ impl From for crate::PartialRole { hoist: value.hoist, rank: value.rank, icon: value.icon.map(|f| f.into()), + owner: value.owner } } } @@ -986,6 +991,7 @@ impl From for FieldsRole { match value { crate::FieldsRole::Colour => FieldsRole::Colour, crate::FieldsRole::Icon => FieldsRole::Icon, + crate::FieldsRole::Owner => FieldsRole::Owner, } } } @@ -995,6 +1001,7 @@ impl From for crate::FieldsRole { match value { FieldsRole::Colour => crate::FieldsRole::Colour, FieldsRole::Icon => crate::FieldsRole::Icon, + FieldsRole::Owner => crate::FieldsRole::Owner, } } } diff --git a/crates/core/models/src/v0/bots.rs b/crates/core/models/src/v0/bots.rs index 70c66c46..08c10788 100644 --- a/crates/core/models/src/v0/bots.rs +++ b/crates/core/models/src/v0/bots.rs @@ -16,6 +16,9 @@ auto_derived!( /// Whether the bot is public /// (may be invited by anyone) pub public: bool, + /// The permissions the bot will ask to have upon being invited to a server, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub default_permissions: Option, /// Whether to enable analytics #[cfg_attr( @@ -124,6 +127,10 @@ auto_derived!( pub name: Option, /// Whether the bot can be added by anyone pub public: Option, + /// The permissions the bot will ask to have upon being invited to a server, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_permissions: Option, + /// Whether analytics should be gathered for this bot /// /// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg). diff --git a/crates/core/models/src/v0/servers.rs b/crates/core/models/src/v0/servers.rs index 2aab765e..548b1a9e 100644 --- a/crates/core/models/src/v0/servers.rs +++ b/crates/core/models/src/v0/servers.rs @@ -109,6 +109,9 @@ auto_derived_partial!( /// Role icon #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub icon: Option, + /// Id of the bot that owns this role, if it is a managed role + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub owner: Option }, "PartialRole" ); @@ -127,6 +130,7 @@ auto_derived!( pub enum FieldsRole { Colour, Icon, + Owner, } /// Channel category diff --git a/crates/delta/src/routes/bots/invite.rs b/crates/delta/src/routes/bots/invite.rs index ad4f16ad..90f255e5 100644 --- a/crates/delta/src/routes/bots/invite.rs +++ b/crates/delta/src/routes/bots/invite.rs @@ -1,9 +1,10 @@ use revolt_database::util::permissions::DatabasePermissionQuery; use revolt_database::{util::reference::Reference, Database, User}; -use revolt_database::{Member, AMQP}; +use revolt_database::{Member, PartialMember, PartialRole, Role, AMQP, AuditLogEntryAction}; +use revolt_database::voice::{sync_voice_permissions, VoiceClient}; use revolt_models::v0; use revolt_permissions::{ - calculate_channel_permissions, calculate_server_permissions, ChannelPermission, + calculate_channel_permissions, calculate_server_permissions, ChannelPermission, Override, }; use revolt_result::{create_error, Result}; use rocket::State; @@ -11,16 +12,17 @@ use rocket::State; use rocket::serde::json::Json; use rocket_empty::EmptyResponse; -/// # Invite Bot -/// -/// Invite a bot to a server or group by its id.` +use crate::util::audit_log_reason::AuditLogReason; + #[openapi(tag = "Bots")] -#[post("//invite", data = "")] +#[post("//invite?", data = "")] pub async fn invite_bot( db: &State, amqp: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, + permissions: Option, dest: Json, ) -> Result { if user.bot.is_some() { @@ -36,16 +38,61 @@ pub async fn invite_bot( match dest.into_inner() { v0::InviteBotDestination::Server { server } => { - let server = db.fetch_server(&server).await?; + let mut server = db.fetch_server(&server).await?; let mut query = DatabasePermissionQuery::new(db, &user).server(&server); - calculate_server_permissions(&mut query) - .await + let user_permissions = calculate_server_permissions(&mut query).await; + user_permissions .throw_if_lacking_channel_permission(ChannelPermission::ManageServer)?; - Member::create(db, &server, &bot_user, None) - .await - .map(|_| EmptyResponse) + let (mut member, _channels) = Member::create(db, &server, &bot_user, None).await?; + + if let Some(permissions) = permissions { + user_permissions + .throw_if_lacking_channel_permission(ChannelPermission::ManageRole)?; + + let requested = Override { + allow: permissions as u64, + deny: 0, + }; + + user_permissions + .throw_permission_override(Override::default(), &requested) + .await?; + + let mut role = Role::create_managed(db, &server, bot_user.username.clone(), bot_user.id.clone()).await?; + + role.update( + db, + &server.id, + PartialRole { + permissions: Some(requested.into()), + ..Default::default() + }, + vec![], + ) + .await?; + + AuditLogEntryAction::RoleCreate { + role: role.id.clone(), + name: role.name.clone(), + } + .insert(db, server.id.clone(), reason, user.id.clone(), None) + .await; + + member + .update( + db, + PartialMember { + roles: Some(vec![role.id.clone()]), + ..Default::default() + }, + vec![], + ) + .await?; + } + + Ok(EmptyResponse) } v0::InviteBotDestination::Group { group } => { let mut channel = db.fetch_channel(&group).await?; diff --git a/crates/delta/src/routes/servers/ban_create.rs b/crates/delta/src/routes/servers/ban_create.rs index 3af2ae8a..c2dc742a 100644 --- a/crates/delta/src/routes/servers/ban_create.rs +++ b/crates/delta/src/routes/servers/ban_create.rs @@ -91,6 +91,8 @@ pub async fn ban( let ban = ServerBan::create(db, &server, target.id, data.reason.clone()).await?; + server.cleanup_managed_bot_role(db, target.id).await?; + AuditLogEntryAction::BanCreate { user: target.id.to_string(), } diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index 35a2f971..c85be7df 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -175,6 +175,10 @@ pub async fn edit( if role.rank <= our_ranking { return Err(create_error!(NotElevated)); } + + if role.is_managed() { + return Err(create_error!(InvalidOperation)); + } } else { return Err(create_error!(InvalidRole)); } diff --git a/crates/delta/src/routes/servers/member_remove.rs b/crates/delta/src/routes/servers/member_remove.rs index 679a2487..435e646b 100644 --- a/crates/delta/src/routes/servers/member_remove.rs +++ b/crates/delta/src/routes/servers/member_remove.rs @@ -52,6 +52,8 @@ pub async fn kick( .remove(db, &server, RemovalIntention::Kick, false) .await?; + server.cleanup_managed_bot_role(db, &member.id.user).await?; + AuditLogEntryAction::MemberKick { user: member.id.user.clone(), } diff --git a/crates/delta/src/routes/servers/roles_delete.rs b/crates/delta/src/routes/servers/roles_delete.rs index ad8885b2..26b89d3c 100644 --- a/crates/delta/src/routes/servers/roles_delete.rs +++ b/crates/delta/src/routes/servers/roles_delete.rs @@ -40,6 +40,10 @@ pub async fn delete( return Err(create_error!(NotElevated)); } + if role.is_managed() { + return Err(create_error!(InvalidOperation)); + } + role.delete(db, &server.id).await?; AuditLogEntryAction::RoleDelete {