feat(enforcement): record decisions and send notices (#3492)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 07:12:46 +02:00
1 parent da1842ecab
commit f5c30fdf8f
383 files changed
+29692 -4139

No files matched your search

+4 -10
View File
@@ -74,9 +74,11 @@ impl AdminApiClient {
pub async fn bulk_delete_user_messages(
&self,
user_ids: &[String],
basis: generated_types::BulkModerationBasis,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
basis,
user_ids: snowflakes(user_ids),
};
self.send(
@@ -91,26 +93,18 @@ impl AdminApiClient {
pub async fn bulk_schedule_user_deletion(
&self,
user_ids: &[String],
reason_code: u32,
days_until_deletion: u32,
public_reason: Option<&str>,
notify_user: bool,
basis: generated_types::BulkModerationBasis,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
basis,
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
user_ids: snowflakes(user_ids),
};
self.send(
+264
View File
@@ -0,0 +1,264 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use reqwest::Method;
use serde::Deserialize;
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{AdminUser, UserMutationResponse};
#[derive(Debug, Deserialize)]
pub struct RestrictionResponse {
pub user: AdminUser,
pub skipped_ips: Vec<generated_types::SkippedNetworkBlock>,
}
pub struct DecisionSubmission {
pub decision: generated_types::EnforcementDecisionInput,
pub internal_note: String,
}
pub struct ClosureRequest {
pub basis: generated_types::ModerationBasis,
pub days_until_deletion: u32,
pub remove_evidence: bool,
pub block_networks: Option<generated_types::NetworkBlockRequest>,
pub replace_pending_deletion_at: Option<chrono::DateTime<chrono::Utc>>,
pub internal_note: Option<String>,
}
impl AdminApiClient {
pub async fn suspend_user(
&self,
user_id: &str,
duration_hours: u32,
remove_evidence: bool,
block_networks: Option<generated_types::NetworkBlockRequest>,
submission: &DecisionSubmission,
) -> ApiResult<RestrictionResponse> {
let body = generated_types::AdminUserBanRequest {
block_networks,
decision: submission.decision.clone(),
duration_hours: i64::from(duration_hours),
remove_evidence: remove_evidence.then_some(true),
};
self.send(
Method::PUT,
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
Some(&body),
Some(&submission.internal_note),
)
.await
}
pub async fn list_network_block_candidates(
&self,
user_id: &str,
) -> ApiResult<generated_types::NetworkBlockCandidatesResponse> {
self.get(
&format!(
"/admin/users/{}/network-block-candidates",
urlencoding::encode(user_id)
),
None,
)
.await
}
pub async fn warn_user(
&self,
user_id: &str,
submission: &DecisionSubmission,
) -> ApiResult<generated_types::AdminUserWarningResponse> {
let body = generated_types::AdminUserWarningRequest {
decision: submission.decision.clone(),
};
self.send(
Method::POST,
&format!("/admin/users/{}/warnings", urlencoding::encode(user_id)),
Some(&body),
Some(&submission.internal_note),
)
.await
}
pub async fn close_account(
&self,
user_id: &str,
request: &ClosureRequest,
) -> ApiResult<RestrictionResponse> {
let body = generated_types::AdminUserDeletionScheduleRequest {
basis: request.basis.clone(),
block_networks: request.block_networks.clone(),
days_until_deletion: crate::api::generated::nonzero_u32(
request.days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
remove_evidence: request.remove_evidence.then_some(true),
replace_pending_deletion_at: request.replace_pending_deletion_at,
};
self.send(
Method::PUT,
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
Some(&body),
request.internal_note.as_deref(),
)
.await
}
pub async fn limit_features(
&self,
user_id: &str,
limit: generated_types::AdminUserFeatureLimitRequestLimit,
ends_at: Option<chrono::DateTime<chrono::Utc>>,
submission: &DecisionSubmission,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserFeatureLimitRequest {
decision: submission.decision.clone(),
ends_at,
limit,
};
let response: UserMutationResponse = self
.send(
Method::PUT,
&format!(
"/admin/users/{}/feature-limits",
urlencoding::encode(user_id)
),
Some(&body),
Some(&submission.internal_note),
)
.await?;
Ok(response.user)
}
pub async fn list_review_queue(
&self,
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
self.get(
"/admin/enforcement-decisions",
Some(&[("review_queue", "true")]),
)
.await
}
pub async fn list_user_decisions(
&self,
user_id: &str,
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
self.get(
"/admin/enforcement-decisions",
Some(&[("subject_type", "user"), ("subject_id", user_id)]),
)
.await
}
pub async fn get_decision(
&self,
decision_id: &str,
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
self.get(
&format!(
"/admin/enforcement-decisions/{}",
urlencoding::encode(decision_id)
),
None,
)
.await
}
pub async fn end_decision(
&self,
decision_id: &str,
outcome: generated_types::EnforcementDecisionEndRequestOutcome,
statement: Option<String>,
internal_note: &str,
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
let body = generated_types::EnforcementDecisionEndRequest { outcome, statement };
self.send(
Method::POST,
&format!(
"/admin/enforcement-decisions/{}/end",
urlencoding::encode(decision_id)
),
Some(&body),
Some(internal_note),
)
.await
}
pub async fn annotate_decision(
&self,
decision_id: &str,
body: String,
) -> ApiResult<generated_types::EnforcementDecisionNoteResponse> {
let body = generated_types::EnforcementDecisionNoteRequest { body };
self.send(
Method::POST,
&format!(
"/admin/enforcement-decisions/{}/notes",
urlencoding::encode(decision_id)
),
Some(&body),
None,
)
.await
}
}
pub fn api_error_message(error: &ApiError) -> Option<String> {
let ApiError::Http { status, message } = error else {
return None;
};
if !matches!(status, 400 | 403 | 404 | 409) {
return None;
}
let body: serde_json::Value = serde_json::from_str(message).ok()?;
let detail = body["errors"]
.as_array()
.and_then(|errors| errors.first())
.and_then(|error| error["message"].as_str());
detail
.or_else(|| body["message"].as_str())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::api_error_message;
use crate::api::client::ApiError;
fn http(status: u16, body: &str) -> ApiError {
ApiError::Http {
status,
message: body.to_owned(),
}
}
#[test]
fn api_error_message_prefers_the_first_field_error() {
let body = r#"{"code":"INVALID_FORM_BODY","message":"Invalid form body","errors":[{"path":"decision.statement","code":"ENFORCEMENT_STATEMENT_NOT_ALLOWED","message":"A note to the user is not allowed for this reason"}]}"#;
assert_eq!(
api_error_message(&http(400, body)).as_deref(),
Some("A note to the user is not allowed for this reason")
);
}
#[test]
fn api_error_message_reads_conflicts_and_ignores_server_errors() {
let body = r#"{"code":"ENFORCEMENT_DECISION_REPLACED","message":"A later decision replaced this one"}"#;
assert_eq!(
api_error_message(&http(409, body)).as_deref(),
Some("A later decision replaced this one")
);
assert_eq!(api_error_message(&http(500, body)), None);
assert_eq!(api_error_message(&http(409, "not json")), None);
assert_eq!(
api_error_message(&ApiError::Network("down".to_owned())),
None
);
}
}
-8
View File
@@ -56,14 +56,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn deletion_reason_code(
value: i32,
field: &str,
) -> Result<types::DeletionReasonCode, String> {
types::DeletionReasonCode::try_from(value)
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
}
pub(crate) fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
+14 -7
View File
@@ -69,13 +69,20 @@ impl AdminApiClient {
Ok(())
}
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let response = self
.generated()
.delete_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
pub async fn delete_guild(
&self,
guild_id: &str,
basis: generated_types::CommunityRemovalBasis,
internal_note: Option<&str>,
) -> ApiResult<SuccessResponse> {
let body = generated_types::AdminGuildDeleteRequest { basis };
self.send(
Method::DELETE,
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
Some(&body),
internal_note,
)
.await
}
pub async fn transfer_guild_ownership(
+40 -27
View File
@@ -4,10 +4,12 @@ use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use crate::templates::components::enforcement_fields::BasisSubmission;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
BrowseChannelResponse, LookupMessageResponse, MessageShredStatusResponse,
SearchChannelMessagesResponse,
};
impl AdminApiClient {
@@ -15,8 +17,10 @@ impl AdminApiClient {
&self,
channel_id: &str,
message_id: &str,
basis: generated_types::MessageDeletionBasis,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminMessageDeleteRequest { basis };
let _: serde_json::Value = self
.send(
Method::DELETE,
@@ -25,7 +29,7 @@ impl AdminApiClient {
urlencoding::encode(channel_id),
urlencoding::encode(message_id)
),
None::<&serde_json::Value>,
Some(&body),
audit_log_reason,
)
.await?;
@@ -54,35 +58,44 @@ impl AdminApiClient {
pub async fn queue_message_shred(
&self,
user_id: &str,
entries: &[serde_json::Value],
) -> ApiResult<MessageShredResponse> {
let entries = entries
.iter()
.cloned()
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::AdminUserMessageShredRequest { entries };
let response = self
.generated()
.shred_admin_user_messages(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
entries: Vec<generated_types::AdminUserMessageShredRequestEntriesItem>,
basis: &BasisSubmission,
) -> ApiResult<generated_types::MessageShredResponse> {
let body = generated_types::AdminUserMessageShredRequest {
basis: basis.moderation_basis(),
entries,
};
self.send(
Method::POST,
&format!(
"/admin/users/{}/message-shreds",
urlencoding::encode(user_id)
),
Some(&body),
basis.internal_note(),
)
.await
}
pub async fn delete_all_user_messages(
&self,
user_id: &str,
dry_run: bool,
) -> ApiResult<DeleteAllUserMessagesResponse> {
let dry_run = if dry_run { "true" } else { "false" };
let response = self
.generated()
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
basis: Option<&BasisSubmission>,
) -> ApiResult<generated_types::DeleteAllUserMessagesResponse> {
let body = generated_types::AdminUserMessageDeleteRequest {
basis: basis.map(BasisSubmission::moderation_basis),
};
let dry_run = if basis.is_some() { "false" } else { "true" };
self.send(
Method::DELETE,
&format!(
"/admin/users/{}/messages?dry_run={dry_run}",
urlencoding::encode(user_id)
),
Some(&body),
basis.and_then(BasisSubmission::internal_note),
)
.await
}
pub async fn get_message_shred_status(
+1
View File
@@ -12,6 +12,7 @@ pub mod bulk;
pub mod client;
pub mod codes;
pub mod discovery;
pub mod enforcement;
pub mod guild_assets;
pub mod guild_threads;
pub mod guilds;
+18 -8
View File
@@ -2,7 +2,13 @@
use reqwest::Method;
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
use crate::api::generated::{
snowflake,
types::{
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
UpdateReportRequestStatus,
},
};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -44,20 +50,24 @@ impl AdminApiClient {
&self,
report_id: &str,
resolution: UpdateReportRequestResolution,
outcome: Option<UpdateReportRequestOutcome>,
duplicate_of: Option<&str>,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
let body = UpdateReportRequest {
duplicate_of: duplicate_of.map(snowflake),
notify_reporter,
outcome,
public_comment: public_comment.map(str::to_owned),
resolution,
status: UpdateReportRequestStatus::Resolved,
};
self.send(
Method::PATCH,
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
audit_log_reason,
None,
)
.await
}
+4
View File
@@ -118,6 +118,8 @@ pub struct AdminUser {
pub deletion_scheduled_by: Option<String>,
#[serde(default)]
pub deletion_scheduled_at: Option<String>,
#[serde(default)]
pub enforcement_decision_id: Option<String>,
pub last_active_at: Option<String>,
pub last_active_ip: Option<String>,
pub last_active_ip_reverse: Option<String>,
@@ -253,6 +255,8 @@ pub struct BanCheckResult {
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub decision_id: Option<String>,
#[serde(default)]
pub entries: Vec<serde_json::Value>,
}
-20
View File
@@ -8,32 +8,12 @@ pub struct LookupMessageResponse {
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MessageShredResponse {
pub success: Option<bool>,
pub job_id: Option<String>,
pub requested: Option<u64>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MessageShredStatusResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeleteAllUserMessagesResponse {
pub success: Option<bool>,
pub dry_run: bool,
pub channel_count: u64,
pub message_count: u64,
pub job_id: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BrowseChannelResponse {
#[serde(flatten)]
+20 -93
View File
@@ -6,6 +6,8 @@ use reqwest::Method;
use crate::api::generated::{snowflake, types as generated_types};
use crate::templates::components::enforcement_fields::BasisSubmission;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
@@ -282,6 +284,7 @@ impl AdminApiClient {
&self,
user_id: &str,
fields: &[String],
basis: &BasisSubmission,
) -> ApiResult<AdminUser> {
let fields = fields
.iter()
@@ -290,14 +293,22 @@ impl AdminApiClient {
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::AdminUserClearFieldsRequest { fields };
let response = self
.generated()
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
let body = generated_types::AdminUserClearFieldsRequest {
basis: basis.moderation_basis(),
fields,
};
let response: UserMutationResponse = self
.send(
Method::DELETE,
&format!(
"/admin/users/{}/profile-fields",
urlencoding::encode(user_id)
),
Some(&body),
basis.internal_note(),
)
.await?;
Ok(response.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
@@ -355,93 +366,28 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn temp_ban_user(
&self,
user_id: &str,
duration_hours: u32,
reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
notify_user,
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
.send(
Method::PUT,
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
Some(&body),
private_reason,
)
.await?;
Ok(resp.user)
}
pub async fn unban_user(
&self,
user_id: &str,
public_reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUnbanRequest {
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated_with_reason(private_reason)?
.unban_admin_user(&snowflake(user_id), &body)
.unban_admin_user(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn schedule_deletion(
&self,
user_id: &str,
reason_code: i32,
public_reason: Option<&str>,
days_until_deletion: u32,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
replace_pending_deletion_at: None,
};
let response = self
.generated_with_reason(audit_log_reason)?
.schedule_admin_user_deletion(&snowflake(user_id), &body)
.await
.map_err(|error| self.generated_error(error))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn cancel_deletion(
&self,
user_id: &str,
expected_pending_deletion_at: &str,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = serde_json::json!({
"expected_pending_deletion_at": expected_pending_deletion_at,
"notify_user": notify_user,
});
let resp: UserMutationResponse = self
.send(
@@ -454,25 +400,6 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn annotate_ban(
&self,
user_id: &str,
ban_audit_log_id: &str,
note: &str,
) -> ApiResult<()> {
let body = serde_json::json!({
"ban_audit_log_id": ban_audit_log_id,
"note": note,
});
self.send_void(
Method::POST,
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
Some(&body),
None,
)
.await
}
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),