feat: bot roles with permissions upon joining a server

Signed-off-by: İspik <ispik@ispik.dev>
This commit is contained in:
İspik
2026-08-04 21:48:45 +03:00
parent fa74826eea
commit 2ad589a70e
11 changed files with 146 additions and 15 deletions

View File

@@ -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<i64>,
/// 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(),

View File

@@ -89,6 +89,13 @@ auto_derived_partial!(
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
/// 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<String>,
},
"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> {
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> {
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<String>,
) -> Result<Self> {
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,
)
);

View File

@@ -184,6 +184,7 @@ impl IntoDocumentPath for FieldsRole {
Some(match self {
FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
FieldsRole::Owner => "owner",
})
}
}

View File

@@ -28,6 +28,7 @@ impl From<crate::Bot> 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<crate::Role> for Role {
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
owner: value.owner
}
}
}
@@ -949,6 +951,7 @@ impl From<Role> 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<crate::PartialRole> for PartialRole {
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
owner: value.owner
}
}
}
@@ -977,6 +981,7 @@ impl From<PartialRole> 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<crate::FieldsRole> 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<FieldsRole> for crate::FieldsRole {
match value {
FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
FieldsRole::Owner => crate::FieldsRole::Owner,
}
}
}

View File

@@ -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<i64>,
/// Whether to enable analytics
#[cfg_attr(
@@ -124,6 +127,10 @@ auto_derived!(
pub name: Option<String>,
/// Whether the bot can be added by anyone
pub public: Option<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<i64>,
/// Whether analytics should be gathered for this bot
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).

View File

@@ -109,6 +109,9 @@ auto_derived_partial!(
/// Role icon
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub icon: Option<File>,
/// 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<String>
},
"PartialRole"
);
@@ -127,6 +130,7 @@ auto_derived!(
pub enum FieldsRole {
Colour,
Icon,
Owner,
}
/// Channel category

View File

@@ -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("/<target>/invite", data = "<dest>")]
#[post("/<target>/invite?<permissions>", data = "<dest>")]
pub async fn invite_bot(
db: &State<Database>,
amqp: &State<AMQP>,
user: User,
reason: AuditLogReason,
target: Reference<'_>,
permissions: Option<i64>,
dest: Json<v0::InviteBotDestination>,
) -> Result<EmptyResponse> {
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?;

View File

@@ -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(),
}

View File

@@ -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));
}

View File

@@ -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(),
}

View File

@@ -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 {