- Log entry "Defender: next level automation"

> Author: Twentysix
> Inserted on: 2021-06-12 12:23:22 +0000
> Total words: 2101
> Estimated reading time: 11 minutes
> Estimated reader's enjoyment: ERROR: Division by zero.
> Tags: red discord
==========================================

Hello there. In my last post I presented my at-the-time new side project, Defender, a automoderation cog packed with modules designed to counter all kinds of bad actors on Discord. It’s been a few months now, and at the time of writing that post, the module that I now consider the main strength of Defender, Warden, did not even exist yet. Let’s give it some well deserved spotlight.

> YAML ain’t that bad after all

Defender was primarily built to counter raids. It does as much well enough, no doubt. I can’t even remember the last time that I have woken up just to see some user wrecking havoc at Red’s: either Defender detects it and the threat is supressed in seconds, or our helpers are able to expel the user by themselves if no staff is online. Truth be told, it only happened three times that our helpers had to dirty their hands and it was just for relatively minor annoyances. And this is because in the meantime Defender has been enhanced in a way that allows me to define rules to counter very specific events. And so, now, if it’s not one of the standard modules that throw baddies out it’s one of the many rules that I have written for Warden: with this module you decide the logic. Tired of pesky name hoisters that sit at the top of the member list in your server? You can rename them automatically. Fed up with randoms joining your server to drop some junk meme pictures in your main channel? Nuh-uh, not anymore!

You can plan for and prevent all sorts of corner cases and patterns of bad behaviour that you notice in your community with Warden. It is so flexible that, in fact, it could effectively replace a good portion of the automodules of Defender, and many of the simplest 3rd party cogs of Red.

# A rule that takes care of name hoisters
rank: 2
name: dehoist
run-every: 15 minutes
event: [on-user-join, manual, periodic]
if:
  - username-matches-any: ["!*"]
  - if-not:
      - nickname-matches-any: ["*"]
do:
  - set-user-nickname: "John Doe the Dehoisted"

Nothing is stopping you from using it for non-moderation automation too and is often suggested as a solution for not-too-complex needs. Sure, installing a security suite to relay a message from channel A to channel B could be seen as overkill, but that never stopped anyone right? At Red’s, other than the sizable amount of security rules, we also use it to trigger the synchronization of bans between main Red and Cog Support server and even relay help messages to new users by identifying them through the rank system of Defender.

> Complex-ing up your rules

At their most basic form Warden rules are fairly simple: just some very plain conditions and actions.

rank: 2
name: respond-to-salute
event: on-message
if:
  - message-matches-any: ["hello", "hi"]
do:
  - send-in-channel: "hi $user_mention"

Now, you might not know jack about Warden but you can probably tell at a glance what the above rule does. The ability to define rules like these was relatively good already, but it did not allow me to define negative conditions: there is a “message-matches-any” but what if I need a “message-does-not-match-any”? Since making a negative condition counterpart for every-single-condition would be very unpractical and also limiting (what about AND, OR, etc?) I have introduced condition blocks:

rank: 1
name: a-very-strict-and-very-pay2win-dehoister
event: [periodic, on-user-join]
run-every: 5 minutes
if:
  - is-staff: false # If the user is NOT staff
  - if-any: # <- Start of condition block 1
     - username-matches-any: ["!*"] # If the user has a name starting with ! ...
     - nickname-matches-any: ["!*"] # ... or a nickname starting with !
  - if-not: # <- Start of condition block 2
     - user-has-any-role-in: ["Patron"] # And DOES NOT have a Patron role ..
     - nickname-matches-any: ["dehoisted"] # ... and also DOES NOT already
                                           # have a "dehoisted" nickname
do:
  - set-user-nickname: "dehoisted"

Woah, this got complex fast. So, what happens here? The gist is, for this rule to trigger, every root condition must evaluate to true: root conditions can either be normal conditions or condition blocks. Of course condition blocks are special: for a if-any to be true, it’s enough for a single condition contained inside them to be true. For a if-not to be considered true, every condition inside the block needs to be false. There is an if-all too, in which all conditions have to be true, but it is not used in this example.
So, what happens if “! John#0000” joins the server? He has no nickname, he certainly isn’t staff and he also hasn’t shelled any money for a patron role (yet). But he is a dirty name hoister.

# Evaluating rule for "! John#0000"
rank: 1
name: a-very-strict-and-very-pay2win-dehoister
event: [periodic, on-user-join]
run-every: 5 minutes
if:
  - is-staff: false # <- root condition: TRUE
  - if-any: # <- root condition-block ANY: TRUE + FALSE = TRUE
     - username-matches-any: ["!*"] # TRUE
     - nickname-matches-any: ["!*"] # FALSE
  - if-not: # <- root condition-block NOT: FALSE + FALSE = TRUE
     - user-has-any-role-in: ["Patron"] # FALSE
     - nickname-matches-any: ["dehoisted"] # FALSE
  # ^ We got 3 root conditions with TRUE, so the conditions have passed!
do:
  - set-user-nickname: "dehoisted"

At the next round (this is a periodic rule, so it’s also evaluated every X minutes… 5 in this case) “Jack#0000” will have the nickname “dehoisted”, thus failing the “nickname-matches-any” of the “if-not” condition block, rendering him ineligible to a second pass. It’s “just” boolean logic, yet introducing condition blocks really stepped up the possible complexity of Warden rules.
That was the easy part… Oh, you thought we were done? :-)

> It’s all about the heat

At that point Warden could evaluate relatively complex conditions, yet it had no state whatsoever. Each run had no memory of previous runs, so it was impossible to detect a user spamming or any other kind of repeating occurrence. Now, I know that I got the inspiration for Warden rules from GitHub Actions, but I’m not really sure from which corner of my mind I’ve had fished out the inspiration for “Heat levels” and “Heat points”, the solution I had came up with.
To better explain them, heat levels are best visualized as a bar, picture the health bar you can find in any videogame:
[••••••••••]
This holds 100 heatpoints but for the sake of easier visualization, let’s pretend that it’s 10. And it’s currently empty. Each user and channel have their own heat levels and Warden rules can add an arbitrary amount of heatpoints to them. Each heatpoint has a finite lifetime, because guess what, heat cools off eventually ;)

- add-user-heatpoints: [5, "5 minutes"]

The above action added 5 heatpoints to our bar above. Each one of them will last 5 minutes, then they will expire.
[XXXXX•••••]
So, how does this solve our no-state issue? Well, it’s simple. Heat levels are shared between rules and separate runs, and not only can rules increase them, they can also check for them

- user-heat-is: 5 # TRUE
- user-heat-more-than: 5 # FALSE

And of course you can also empty the bar at will, if you don’t want to wait for its natural expiration

- empty-user-heat:

You can have rules that enter into effect only after a certain threshold has been reached if you wish. Sounds good right? You can replicate the entire “raider detection” module thanks to this system, because you’re not just able to increment a counter, you’re even able to determine if X messages have been sent in Y minutes thanks to the expiration logic. This was great and all but in practice it was still a bit limiting, because there are mad men out there with a hundred rules, and guess what? One bar per-user and one bar per-channel is not nearly enough when you have many different sets of rules that do different things. And just like that, custom heatlevels were born. To illustrate them, I’ll just jump right in with the syntax

# Conditions:
- custom-heat-is: ["random-word", 5]
- custom-heat-more-than: ["random-word", 5]
# Actions:
- add-user-heatpoint: ["random-word", "5 minutes"] # Adds one heatpoint
- add-user-heatpoints: ["random-word", 5, "5 minutes"] # Adds 5 heatpoints

If before our heat level bar was attached to a user or channel, here it’s attached to a label that we can define ourselves. This, alone, raises the potential of heat levels but it skyrockets when used in conjuction to context variables:

- add-user-heatpoint: ["my_rule-$channel_id-$user_id", "5 minutes"]
# At runtime, this will be evaluated to...
- add-user-heatpoint: ["my_rule-133081046269731172-119079430642466111", "5 minutes"]

As you might guess from the example, context variables are dynamic variables that hold data related to the current context of the rule, so, in the on-message event $channel_id will hold the channel id in which the message was sent and $user_id the id of the message’s author. Thanks to custom heat levels, not only it’s possible to define many different heat levels for each rule, it’s even possible to dynamically assign them, so a single user could have many indipendent custom heat levels in each different channels of your server!
Recently I needed to implement a time-based mechanism to prevent notification spam to the staff, picture a user spamming rule-breaking messages. I have coded a system from scratch but then I thought “Wait a moment…“. I reverted everything and replaced it with about 3 lines of heat-level logic:

elif action == Action.NoAction:
    heat_key = f"core-ca-{message.channel.id}-{author.id}"
    if heat.get_custom_heat(guild, heat_key) > 0:
        with contextlib.suppress(discord.HTTPException, discord.Forbidden):
            await message.delete() # The user is spamming, delete and return
        return
    heat.increase_custom_heat(guild, heat_key, datetime.timedelta(minutes=15))

await self.send_notification(guild, text, title=EMBED_TITLE,
                             fields=EMBED_FIELDS, jump_to=message)

Turns out that all I needed for a time-based spam prevention system was already there… I just didn’t think of using it ouside of Warden yet :-)
Thanks for reading, hopefully you’ve found the content interesting. Until next time!