serenity/model/guild/
automod.rs

1//! Auto moderation types
2//!
3//! [Discord docs](https://discord.com/developers/docs/resources/auto-moderation)
4
5use std::time::Duration;
6
7use serde::de::{Deserializer, Error};
8use serde::ser::Serializer;
9use serde::{Deserialize, Serialize};
10
11use crate::model::id::*;
12
13/// Configured auto moderation rule.
14///
15/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object).
16// TODO: should be renamed to a less ambiguous name
17#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
18#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
19#[non_exhaustive]
20pub struct Rule {
21    /// ID of the rule.
22    pub id: RuleId,
23    /// ID of the guild this rule belongs to.
24    pub guild_id: GuildId,
25    /// Name of the rule.
26    pub name: String,
27    /// ID of the user which created the rule.
28    pub creator_id: UserId,
29    /// Event context in which the rule should be checked.
30    pub event_type: EventType,
31    /// Characterizes the type of content which can trigger the rule.
32    #[serde(flatten)]
33    pub trigger: Trigger,
34    /// Actions which will execute when the rule is triggered.
35    pub actions: Vec<Action>,
36    /// Whether the rule is enabled.
37    pub enabled: bool,
38    /// Roles that should not be affected by the rule.
39    ///
40    /// Maximum of 20.
41    pub exempt_roles: Vec<RoleId>,
42    /// Channels that should not be affected by the rule.
43    ///
44    /// Maximum of 50.
45    pub exempt_channels: Vec<ChannelId>,
46}
47
48/// Indicates in what event context a rule should be checked.
49///
50/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-event-types).
51#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
52#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
53#[serde(from = "u8", into = "u8")]
54#[non_exhaustive]
55pub enum EventType {
56    MessageSend,
57    Unknown(u8),
58}
59
60impl From<u8> for EventType {
61    fn from(value: u8) -> Self {
62        match value {
63            1 => Self::MessageSend,
64            _ => Self::Unknown(value),
65        }
66    }
67}
68
69impl From<EventType> for u8 {
70    fn from(value: EventType) -> Self {
71        match value {
72            EventType::MessageSend => 1,
73            EventType::Unknown(unknown) => unknown,
74        }
75    }
76}
77
78/// Characterizes the type of content which can trigger the rule.
79///
80/// Discord docs:
81/// [type](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-trigger-types),
82/// [metadata](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-trigger-metadata)
83#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
84#[derive(Clone, Debug, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum Trigger {
87    Keyword {
88        /// Substrings which will be searched for in content (Maximum of 1000)
89        ///
90        /// A keyword can be a phrase which contains multiple words.
91        /// [Wildcard symbols](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-keyword-matching-strategies)
92        /// can be used to customize how each keyword will be matched. Each keyword must be 60
93        /// characters or less.
94        strings: Vec<String>,
95        /// Regular expression patterns which will be matched against content (Maximum of 10)
96        regex_patterns: Vec<String>,
97        /// Substrings which should not trigger the rule (Maximum of 100 or 1000)
98        allow_list: Vec<String>,
99    },
100    Spam,
101    KeywordPreset {
102        /// The internally pre-defined wordsets which will be searched for in content
103        presets: Vec<KeywordPresetType>,
104        /// Substrings which should not trigger the rule (Maximum of 100 or 1000)
105        allow_list: Vec<String>,
106    },
107    MentionSpam {
108        /// Total number of unique role and user mentions allowed per message (Maximum of 50)
109        mention_total_limit: u8,
110    },
111    Unknown(u8),
112}
113
114/// Helper struct for the (de)serialization of `Trigger`.
115#[derive(Deserialize, Serialize)]
116#[serde(rename = "Trigger")]
117struct InterimTrigger {
118    #[serde(rename = "trigger_type")]
119    kind: TriggerType,
120    #[serde(rename = "trigger_metadata")]
121    metadata: TriggerMetadata,
122}
123
124impl<'de> Deserialize<'de> for Trigger {
125    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
126        let trigger = InterimTrigger::deserialize(deserializer)?;
127        let trigger = match trigger.kind {
128            TriggerType::Keyword => Self::Keyword {
129                strings: trigger
130                    .metadata
131                    .keyword_filter
132                    .ok_or_else(|| Error::missing_field("keyword_filter"))?,
133                regex_patterns: trigger
134                    .metadata
135                    .regex_patterns
136                    .ok_or_else(|| Error::missing_field("regex_patterns"))?,
137                allow_list: trigger
138                    .metadata
139                    .allow_list
140                    .ok_or_else(|| Error::missing_field("allow_list"))?,
141            },
142            TriggerType::Spam => Self::Spam,
143            TriggerType::KeywordPreset => Self::KeywordPreset {
144                presets: trigger.metadata.presets.ok_or_else(|| Error::missing_field("presets"))?,
145                allow_list: trigger
146                    .metadata
147                    .allow_list
148                    .ok_or_else(|| Error::missing_field("allow_list"))?,
149            },
150            TriggerType::MentionSpam => Self::MentionSpam {
151                mention_total_limit: trigger
152                    .metadata
153                    .mention_total_limit
154                    .ok_or_else(|| Error::missing_field("mention_total_limit"))?,
155            },
156            TriggerType::Unknown(unknown) => Self::Unknown(unknown),
157        };
158        Ok(trigger)
159    }
160}
161
162impl Serialize for Trigger {
163    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164        let mut trigger = InterimTrigger {
165            kind: self.kind(),
166            metadata: TriggerMetadata {
167                keyword_filter: None,
168                regex_patterns: None,
169                presets: None,
170                allow_list: None,
171                mention_total_limit: None,
172            },
173        };
174        match self {
175            Self::Keyword {
176                strings,
177                regex_patterns,
178                allow_list,
179            } => {
180                trigger.metadata.keyword_filter = Some(strings.clone());
181                trigger.metadata.regex_patterns = Some(regex_patterns.clone());
182                trigger.metadata.allow_list = Some(allow_list.clone());
183            },
184            Self::KeywordPreset {
185                presets,
186                allow_list,
187            } => {
188                trigger.metadata.presets = Some(presets.clone());
189                trigger.metadata.allow_list = Some(allow_list.clone());
190            },
191            Self::MentionSpam {
192                mention_total_limit,
193            } => trigger.metadata.mention_total_limit = Some(*mention_total_limit),
194            Self::Spam | Self::Unknown(_) => {},
195        }
196        trigger.serialize(serializer)
197    }
198}
199
200impl Trigger {
201    #[must_use]
202    pub fn kind(&self) -> TriggerType {
203        match self {
204            Self::Keyword {
205                ..
206            } => TriggerType::Keyword,
207            Self::Spam => TriggerType::Spam,
208            Self::KeywordPreset {
209                ..
210            } => TriggerType::KeywordPreset,
211            Self::MentionSpam {
212                ..
213            } => TriggerType::MentionSpam,
214            Self::Unknown(unknown) => TriggerType::Unknown(*unknown),
215        }
216    }
217}
218
219/// Type of [`Trigger`].
220///
221/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-trigger-types).
222#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
223#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
224#[serde(from = "u8", into = "u8")]
225#[non_exhaustive]
226pub enum TriggerType {
227    Keyword,
228    Spam,
229    KeywordPreset,
230    MentionSpam,
231    Unknown(u8),
232}
233
234impl From<u8> for TriggerType {
235    fn from(value: u8) -> Self {
236        match value {
237            1 => Self::Keyword,
238            3 => Self::Spam,
239            4 => Self::KeywordPreset,
240            5 => Self::MentionSpam,
241            _ => Self::Unknown(value),
242        }
243    }
244}
245
246impl From<TriggerType> for u8 {
247    fn from(value: TriggerType) -> Self {
248        match value {
249            TriggerType::Keyword => 1,
250            TriggerType::Spam => 3,
251            TriggerType::KeywordPreset => 4,
252            TriggerType::MentionSpam => 5,
253            TriggerType::Unknown(unknown) => unknown,
254        }
255    }
256}
257
258/// Individual change for trigger metadata within an audit log entry.
259///
260/// Different fields are relevant based on the value of trigger_type. See
261/// [`Change::TriggerMetadata`].
262///
263/// [`Change::TriggerMetadata`]: crate::model::guild::audit_log::Change::TriggerMetadata
264///
265/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-trigger-metadata).
266#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
267#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
268#[non_exhaustive]
269pub struct TriggerMetadata {
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub keyword_filter: Option<Vec<String>>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub regex_patterns: Option<Vec<String>>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub presets: Option<Vec<KeywordPresetType>>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub allow_list: Option<Vec<String>>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub mention_total_limit: Option<u8>,
280}
281
282/// Internally pre-defined wordsets which will be searched for in content.
283///
284/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-rule-object-keyword-preset-types).
285#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
286#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
287#[serde(from = "u8", into = "u8")]
288#[non_exhaustive]
289pub enum KeywordPresetType {
290    /// Words that may be considered forms of swearing or cursing
291    Profanity,
292    /// Words that refer to sexually explicit behavior or activity
293    SexualContent,
294    /// Personal insults or words that may be considered hate speech
295    Slurs,
296    Unknown(u8),
297}
298
299impl From<u8> for KeywordPresetType {
300    fn from(value: u8) -> Self {
301        match value {
302            1 => Self::Profanity,
303            2 => Self::SexualContent,
304            3 => Self::Slurs,
305            _ => Self::Unknown(value),
306        }
307    }
308}
309
310impl From<KeywordPresetType> for u8 {
311    fn from(value: KeywordPresetType) -> Self {
312        match value {
313            KeywordPresetType::Profanity => 1,
314            KeywordPresetType::SexualContent => 2,
315            KeywordPresetType::Slurs => 3,
316            KeywordPresetType::Unknown(unknown) => unknown,
317        }
318    }
319}
320
321/// An action which will execute whenever a rule is triggered.
322///
323/// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-action-object).
324#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
325#[derive(Clone, Debug, PartialEq, Eq)]
326#[non_exhaustive]
327pub enum Action {
328    /// Blocks the content of a message according to the rule.
329    BlockMessage {
330        /// Additional explanation that will be shown to members whenever their message is blocked
331        ///
332        /// Maximum of 150 characters
333        custom_message: Option<String>,
334    },
335    /// Logs user content to a specified channel.
336    Alert(ChannelId),
337    /// Timeout user for a specified duration.
338    ///
339    /// Maximum of 2419200 seconds (4 weeks).
340    ///
341    /// A `Timeout` action can only be setup for [`Keyword`] rules. The [Moderate Members]
342    /// permission is required to use the `Timeout` action type.
343    ///
344    /// [`Keyword`]: TriggerType::Keyword
345    /// [Moderate Members]: crate::model::Permissions::MODERATE_MEMBERS
346    Timeout(Duration),
347    Unknown(u8),
348}
349
350/// Gateway event payload sent when a rule is triggered and an action is executed (e.g. message is
351/// blocked).
352///
353/// [Discord docs](https://discord.com/developers/docs/topics/gateway-events#auto-moderation-action-execution).
354#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
355#[derive(Clone, Debug, Deserialize, Serialize)]
356#[non_exhaustive]
357pub struct ActionExecution {
358    /// ID of the guild in which the action was executed.
359    pub guild_id: GuildId,
360    /// Action which was executed.
361    pub action: Action,
362    /// ID of the rule which action belongs to.
363    pub rule_id: RuleId,
364    /// Trigger type of rule which was triggered.
365    #[serde(rename = "rule_trigger_type")]
366    pub trigger_type: TriggerType,
367    /// ID of the user which generated the content which triggered the rule.
368    pub user_id: UserId,
369    /// ID of the channel in which user content was posted.
370    pub channel_id: Option<ChannelId>,
371    /// ID of any user message which content belongs to.
372    ///
373    /// Will be `None` if message was blocked by automod or content was not part of any message.
374    pub message_id: Option<MessageId>,
375    /// ID of any system auto moderation messages posted as a result of this action.
376    ///
377    /// Will be `None` if this event does not correspond to an action with type [`Action::Alert`].
378    pub alert_system_message_id: Option<MessageId>,
379    /// User generated text content.
380    ///
381    /// Requires [`GatewayIntents::MESSAGE_CONTENT`] to receive non-empty values.
382    ///
383    /// [`GatewayIntents::MESSAGE_CONTENT`]: crate::model::gateway::GatewayIntents::MESSAGE_CONTENT
384    pub content: String,
385    /// Word or phrase configured in the rule that triggered the rule.
386    pub matched_keyword: Option<String>,
387    /// Substring in content that triggered the rule.
388    ///
389    /// Requires [`GatewayIntents::MESSAGE_CONTENT`] to receive non-empty values.
390    ///
391    /// [`GatewayIntents::MESSAGE_CONTENT`]: crate::model::gateway::GatewayIntents::MESSAGE_CONTENT
392    pub matched_content: Option<String>,
393}
394
395/// Helper struct for the (de)serialization of `Action`.
396#[derive(Default, Deserialize, Serialize)]
397struct RawActionMetadata {
398    #[serde(skip_serializing_if = "Option::is_none")]
399    channel_id: Option<ChannelId>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    duration_seconds: Option<u64>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    custom_message: Option<String>,
404}
405
406/// Helper struct for the (de)serialization of `Action`.
407#[derive(Deserialize, Serialize)]
408struct RawAction {
409    #[serde(rename = "type")]
410    kind: ActionType,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    metadata: Option<RawActionMetadata>,
413}
414
415// The manual implementation is required because serde doesn't support integer tags for
416// internally/adjacently tagged enums.
417//
418// See [Integer/boolean tags for internally/adjacently tagged enums](https://github.com/serde-rs/serde/pull/2056).
419impl<'de> Deserialize<'de> for Action {
420    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
421        let action = RawAction::deserialize(deserializer)?;
422        Ok(match action.kind {
423            ActionType::BlockMessage => Action::BlockMessage {
424                custom_message: action.metadata.and_then(|m| m.custom_message),
425            },
426            ActionType::Alert => Action::Alert(
427                action
428                    .metadata
429                    .ok_or_else(|| Error::missing_field("metadata"))?
430                    .channel_id
431                    .ok_or_else(|| Error::missing_field("channel_id"))?,
432            ),
433            ActionType::Timeout => Action::Timeout(Duration::from_secs(
434                action
435                    .metadata
436                    .ok_or_else(|| Error::missing_field("metadata"))?
437                    .duration_seconds
438                    .ok_or_else(|| Error::missing_field("duration_seconds"))?,
439            )),
440            ActionType::Unknown(unknown) => Action::Unknown(unknown),
441        })
442    }
443}
444
445impl Serialize for Action {
446    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
447        let action = match self.clone() {
448            Action::BlockMessage {
449                custom_message,
450            } => RawAction {
451                kind: ActionType::BlockMessage,
452                metadata: Some(RawActionMetadata {
453                    custom_message,
454                    ..Default::default()
455                }),
456            },
457            Action::Alert(channel_id) => RawAction {
458                kind: ActionType::Alert,
459                metadata: Some(RawActionMetadata {
460                    channel_id: Some(channel_id),
461                    ..Default::default()
462                }),
463            },
464            Action::Timeout(duration) => RawAction {
465                kind: ActionType::Timeout,
466                metadata: Some(RawActionMetadata {
467                    duration_seconds: Some(duration.as_secs()),
468                    ..Default::default()
469                }),
470            },
471            Action::Unknown(n) => RawAction {
472                kind: ActionType::Unknown(n),
473                metadata: None,
474            },
475        };
476        action.serialize(serializer)
477    }
478}
479
480impl Action {
481    #[must_use]
482    pub fn kind(&self) -> ActionType {
483        match self {
484            Self::BlockMessage {
485                ..
486            } => ActionType::BlockMessage,
487            Self::Alert(_) => ActionType::Alert,
488            Self::Timeout(_) => ActionType::Timeout,
489            Self::Unknown(unknown) => ActionType::Unknown(*unknown),
490        }
491    }
492}
493
494enum_number! {
495    /// See [`Action`]
496    ///
497    /// [Discord docs](https://discord.com/developers/docs/resources/auto-moderation#auto-moderation-action-object-action-types).
498    #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
499    #[serde(from = "u8", into = "u8")]
500    #[non_exhaustive]
501    pub enum ActionType {
502        BlockMessage = 1,
503        Alert = 2,
504        Timeout = 3,
505        _ => Unknown(u8),
506    }
507}
508
509#[cfg(test)]
510mod tests {
511
512    use super::*;
513    use crate::json::{assert_json, json};
514
515    #[test]
516    fn rule_trigger_serde() {
517        #[derive(Debug, PartialEq, Deserialize, Serialize)]
518        struct Rule {
519            #[serde(flatten)]
520            trigger: Trigger,
521        }
522
523        assert_json(
524            &Rule {
525                trigger: Trigger::Keyword {
526                    strings: vec![String::from("foo"), String::from("bar")],
527                    regex_patterns: vec![String::from("d[i1]ck")],
528                    allow_list: vec![String::from("duck")],
529                },
530            },
531            json!({"trigger_type": 1, "trigger_metadata": {"keyword_filter": ["foo", "bar"], "regex_patterns": ["d[i1]ck"], "allow_list": ["duck"]}}),
532        );
533
534        assert_json(
535            &Rule {
536                trigger: Trigger::Spam,
537            },
538            json!({"trigger_type": 3, "trigger_metadata": {}}),
539        );
540
541        assert_json(
542            &Rule {
543                trigger: Trigger::KeywordPreset {
544                    presets: vec![
545                        KeywordPresetType::Profanity,
546                        KeywordPresetType::SexualContent,
547                        KeywordPresetType::Slurs,
548                    ],
549                    allow_list: vec![String::from("boob")],
550                },
551            },
552            json!({"trigger_type": 4, "trigger_metadata": {"presets": [1,2,3], "allow_list": ["boob"]}}),
553        );
554
555        assert_json(
556            &Rule {
557                trigger: Trigger::MentionSpam {
558                    mention_total_limit: 7,
559                },
560            },
561            json!({"trigger_type": 5, "trigger_metadata": {"mention_total_limit": 7}}),
562        );
563
564        assert_json(
565            &Rule {
566                trigger: Trigger::Unknown(123),
567            },
568            json!({"trigger_type": 123, "trigger_metadata": {}}),
569        );
570    }
571
572    #[test]
573    fn action_serde() {
574        assert_json(
575            &Action::BlockMessage {
576                custom_message: None,
577            },
578            json!({"type": 1, "metadata": {}}),
579        );
580
581        assert_json(
582            &Action::Alert(ChannelId::new(123)),
583            json!({"type": 2, "metadata": {"channel_id": "123"}}),
584        );
585
586        assert_json(
587            &Action::Timeout(Duration::from_secs(1024)),
588            json!({"type": 3, "metadata": {"duration_seconds": 1024}}),
589        );
590
591        assert_json(&Action::Unknown(123), json!({"type": 123}));
592    }
593}