Working with Automod as a Labeler (Draft)
Poorly written by hailey on January 2, 2025Since we initially rolled out Ozone to third parties, there have been a number of labelers pop up across the network. From the game labelers like TTRPG to utilities like the Pronouns labeler to true moderation labelers such as Laelaps or Asuka Field. TTRPG and Pronouns both operate automatically. The TTRPG labeler assigns labels at random that list the given user’s class, and the Pronouns labeler “reacts” to likes on the network, assigning the user’s selected pronouns once they like a post. For the most part, these two labelers have a pretty easy time - at least in terms of “moderation” work.
On the other hand, Laelaps and Asuka Field are much more hands on. Users report posts or accounts within the app to either of these two labelers, and moderators at either of the two labelers respond by actioning and/or acknowledging the incoming reports. At a small scale, this works pretty well. Check in every so often, handle a few reports. However, once you start scaling - and some of these labelers definitely have! - things become much more time consuming. The official Bluesky moderation service has a similar problem, and as a result, one piece of the puzzle that we created as our automod system (called Hepa, like the air filters). It helps us take action on posts and accounts automatically for a whole slew of reasons, but it is also quite powerful. Wouldn’t it be nice if third parties could benefit as well?
In this post, we’ll explore three different ways that automod rules can be implemented using Hepa, all with - as we can see from these active lablers - real world applications.
Before we start
One thing to note before we begin is that as of right now, you need to write some Go to implement rules. The skill level required is quite low for most application (in fact, the first time I ever really wrote Go was writing automod rules, and now I’m in love with the language) and following simple patterns makes a lot of code re-usable. In the future, we would like to have a more robust system, possibly either in a much simpler scripting language or even some sort of no-code system to implement rules.
I’m going to assume a few things:
- You either know Go or are comfortable learning
- You know to “nil check” pointers in Go (if you didn’t now you do)
- You’re already running an Ozone instance
- You are comfortable doing a small bit of sysadmin work (again, if you’re running Ozone I think this is a safe assumption!)
- You have redis-server installed
Let’s also get a little bit of configuration out of the way. We’ll clone the repo and set some basic settings in the environment for our automod instance.
git clone [email protected]:bluesky-social/indigo.git
cd indigo
git checkout hailey/self-hosted-ozone-automod # For now, until I merge
Create a .env
file, and add the following:
HEPA_MODE="labeler" # This allows us to use regular authentication
HEPA_FIREHOSE_PARALLELISM=600 # Arbitrary number. Shouldn't be too low, but probably doesn't need to be this high either.
HEPA_REDIS_URL="redis://localhost:6379/0"
HEPA_PLC_RATE_LIMIT=800
HEPA_QUOTA_MOD_ACTION_DAY=5000
ATP_OZONE_HOST="https://ozone.haileyok.com" # Host of the ozone instance
HEPA_OZONE_SERVICE_DID="did:plc:abc123" # This is the did of the labeler account itself.
HEPA_OZONE_DID="did:plc:123456" # This is the did of the moderator account
HEPA_OZONE_MOD_PASS="" # The password for the moderator account
HEPA_OZONE_MOD_SERVICE="https://inkcap.us-east.host.bsky.network" # The PDS url the moderator account is on. Do not simply use bsky.social, use the actual PDS host
ATP_BSKY_HOST="https://api.bsky.app" # You probably don't want to change this
ATP_BGS_HOST="wss://bsky.network" # You probably want this too. If you're already subscribing to the firehose for other services, you can use Rainbow!
HEPA_LOG_LEVEL=warn
HEPA_METRICS_LISTEN=":3989" # Optional prometheus metrics for observability
Finally, remove all of the rules that are active in automod/rules/all.go
. All of the rules should be either commented out or removed entirely from the various arrays in this file before attempting to run Hepa.
Once you have removed those rules, try running Automod with go run ./cmd/hepa run
. You shouldn’t get any errors, and you’ll probably have an empty console (Note: you may encounter errors with looking up profiles. This is a known “bug” right now, but simply means the profile for an event
Pronouns Bot
We’ll start with the pronouns example. While Hepa might be a bit hefty for something like assigning pronouns to users, it is a great example to illustrate. We are going to create a “record rule” for this, since there isn’t a “like rule” implemented.
We’ll begin by creating a post that we want to track the likes on and get the URI of that post. Then, we can write the rule!
package rules
import "github.com/bluesky-social/indigo/automod"
// We'll create a map of all the different URIs that we're looking for
var pronounsPostMap = map[string]string{
"at://did:plc:123/app.bsky.feed.post/1234": "she-her",
}
// Record rules take in a record context and return an error
func PronounsLikeRule(c *automod.RecordContext) error {
// `Account` contains various pieces of metadata that are hydrated for you. If the identity is `nil`, then soemthing went wrong while hydrating that, so we'll skip the rule
if c.Account.Identity == nil {
return nil
}
// We don't care about this event if it isn't a like event. So, we'll skip.
if c.RecordOp.Collection != "app.bsky.feed.like" {
return nil
}
// If the url isn't inside of our post map, we also don't care so we'll skip
label, ok := pronounsPostMap[c.RecordOp.ATURI().String()]
if !ok {
return nil
}
// If the action is a "create" action, then we'll apply the label
if c.RecordOp.Action == "create" {
c.AddAccountLabel(label)
}
// We don't have a RemoveAccountLabel...heh
return nil
}
See? That was super easy! There isn’t much to go over here, but we can touch on what exactly is available inside of Account
. Account
is hydrated whenever an event is received, and by the time your rule executes it will be available to you. The metadata gets cached in Redis for some time so as to not constantly re-fetch data that already exists.
Profile metadata such as follow counts, display name, bio description, or avatar and banner CIDs are all inside of Account
. While they are not too useful for this rule, they can play a vital role in determining whether or not to action an account as we’ll see in future rules.
Note: Any time that you encounter a pointer, you want to check for nil
before trying to access the value. For example, the DisplayName
field is a pointer, because it may not be set by the user (this is a weird quirk of Golang). To do so, you might write
if c.Account.Profile.DisplayName != nil && *c.Account.Profile.DisplayName == "hailey" {
c.AddAccountLabel("hailey")
}
Hepa will graceful handle null pointers, however your rules may fail to execute properly as a result so you should always pay close attention. If null pointers occur, they will be logged by Hepa.
To test our rule, let’s update all.go
with the new rule we just created. Your all.go
may look like this:
package rules
import (
"github.com/bluesky-social/indigo/automod"
)
// IMPORTANT: reminder that these are the indigo-edition rules, not production rules
func DefaultRules() automod.RuleSet {
rules := automod.RuleSet{
PostRules: []automod.PostRuleFunc{},
ProfileRules: []automod.ProfileRuleFunc{},
RecordRules: []automod.RecordRuleFunc{
PronounsLikeRule,
},
RecordDeleteRules: []automod.RecordRuleFunc{},
IdentityRules: []automod.IdentityRuleFunc{},
BlobRules: []automod.BlobRuleFunc{},
NotificationRules: []automod.NotificationRuleFunc{},
OzoneEventRules: []automod.OzoneEventRuleFunc{},
}
return rules
}
Now all you need to do is run go run ./cmd/hepa run
, like the post, and watch your labeler apply the label when you like it.