# Moderation API Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including: - Moderator labeling - User muting (including mutelists) - User blocking - Mutewords - Hidden posts ## Configuration Every moderation function takes a set of options which look like this: ```typescript { // the logged-in user's DID userDid: 'did:plc:1234...', prefs: { // is adult content allowed? adultContentEnabled: true, // the global label settings (used on self-labels) labels: { porn: 'hide', sexual: 'warn', nudity: 'ignore', // ... }, // the subscribed labelers and their label settings labelers: [ { did: 'did:plc:1234...', labels: { porn: 'hide', sexual: 'warn', nudity: 'ignore', // ... } } ], mutedWords: [/* ... */], hiddenPosts: [/* ... */] }, // custom label definitions labelDefs: { // labelerDid => defs[] 'did:plc:1234...': [ /* ... */ ] } } ``` This should match the following interfaces: ```typescript export interface ModerationPrefsLabeler { did: DidString labels: Record } export interface ModerationPrefs { adultContentEnabled: boolean labels: Record labelers: ModerationPrefsLabeler[] mutedWords: app.bsky.actor.defs.MutedWord[] hiddenPosts: AtUriString[] } export interface ModerationOpts { userDid: DidString | undefined prefs: ModerationPrefs /** * Map of labeler did -> custom definitions */ labelDefs?: Record } ``` You can quickly grab the `ModerationPrefs` using the `getPreferences` action: ```typescript import { getPreferences } from '@bsky/sdk' import { moderatePost } from '@bsky/sdk/moderation' const prefs = await client.call(getPreferences) moderatePost(post, { userDid: /*...*/, prefs: prefs.moderationPrefs, labelDefs: /*...*/ }) ``` To gather the label definitions (`labelDefs`) see the _Labelers_ section below. ## Labelers Labelers are services that provide moderation labels. Your application will typically have 1+ top-level labelers set with the ability to do "takedowns" on content. This is controlled via the `appLabelers` client option. When no labeler headers are sent at all, the Bluesky API applies its own moderation service by default — so this only needs to be set when introducing other labelers: ```typescript import { Client } from '@atproto/lex' const client = new Client(session, { appLabelers: ['did:web:my-labeler.com'], }) ``` (`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.) Users may also add their own labelers. The active labelers are controlled via an HTTP header which is set by the client from its labelers configuration (`client.setLabelers()`, `client.addLabelers()`). Use the `addLabeler`/`removeLabeler` actions to persist a user's labeler subscriptions in their preferences. Labelers publish a `app.bsky.labeler.service` record that looks like this: ```js { $type: 'app.bsky.labeler.service', policies: { // the list of label values the labeler will publish labelValues: [ 'rude', ], // any custom definitions the labeler will be using labelValueDefinitions: [ { identifier: 'rude', blurs: 'content', severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ { lang: 'en', name: 'Rude', description: 'Not keeping things civil.', }, ], }, ], }, createdAt: '2024-03-12T17:17:17.215Z' } ``` The label value definitions are custom labels which only apply to that labeler. Your client needs to sync those definitions in order to correctly interpret them. To do that, call `app.bsky.labeler.getServices` with `detailed: true` periodically to fetch their definitions. We recommend caching the response (at time of our writing the official client uses a TTL of 6 hours). Here is how to do this: ```typescript import { Client } from '@atproto/lex' import { getPreferences } from '@bsky/sdk' import { app } from '@bsky/sdk/lexicons' import { type InterpretedLabelValueDefinition, interpretLabelValueDefinitions, moderatePost, } from '@bsky/sdk/moderation' // assume `client` is backed by a signed-in session const prefs = await client.call(getPreferences) const services = await client.call(app.bsky.labeler.getServices, { dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did), detailed: true, }) const labelDefs: Record = {} for (const view of services.views) { if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) { labelDefs[view.creator.did] = interpretLabelValueDefinitions(view) } } moderatePost(post, { userDid: client.assertDid, prefs: prefs.moderationPrefs, labelDefs, }) ``` ## The `moderate*()` APIs The SDK exports methods to moderate the different kinds of content on the network. ```typescript import { moderateProfile, moderatePost, moderateNotification, moderateFeedGenerator, moderateUserList, moderateStatus, moderateViewExternal, moderateProfileLink, } from '@bsky/sdk/moderation' ``` Each of these follows the same API signature: ```typescript const res = moderatePost(post, moderationOptions) ``` The response object provides an API for figuring out what your UI should do in different contexts. ```typescript res.ui(context) /* => ModerationUI { filter: boolean // should the content be removed from the interface? blur: boolean // should the content be put behind a cover? alert: boolean // should an alert be put on the content? (negative) inform: boolean // should an informational notice be put on the content? (neutral) noOverride: boolean // if blur=true, should the UI disable opening the cover? // the reasons for each of the flags: filters: ModerationCause[] blurs: ModerationCause[] alerts: ModerationCause[] informs: ModerationCause[] } */ ``` There are multiple UI contexts available: - `profileList` A profile being listed, eg in search or a follower list - `profileView` A profile being viewed directly - `avatar` The user's avatar in any context - `banner` The user's banner in any context - `displayName` The user's display name in any context - `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc - `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc - `contentMedia ` Media inside the content, eg a picture embedded in a post Here's how a post in a feed would use these tools to make a decision: ```typescript const mod = moderatePost(post, moderationOptions) if (mod.ui('contentList').filter) { // dont show the post } if (mod.ui('contentList').blur) { // cover the post with the explanation from mod.ui('contentList').blurs[0] if (mod.ui('contentList').noOverride) { // dont allow the cover to be removed } } if (mod.ui('contentMedia').blur) { // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0] if (mod.ui('contentMedia').noOverride) { // dont allow the cover to be removed } } if (mod.ui('avatar').blur) { // cover the avatar with the explanation from mod.ui('avatar').blurs[0] if (mod.ui('avatar').noOverride) { // dont allow the cover to be removed } } for (const alert of mod.ui('contentList').alerts) { // render this alert } for (const inform of mod.ui('contentList').informs) { // render this inform } ``` ### External embeds Labels can be attached directly to an `app.bsky.embed.external#viewExternal` (for example, on link cards backed by Atmosphere records). These labels are not covered by `moderatePost`, so compute a separate decision for the embed with `moderateViewExternal` and apply it to the rendered card. An external card acts as both the "content" and the "media" of its embed, so combine the `contentView` and `contentMedia` contexts with `mergeModUIResults`: ```typescript import { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation' const embedMod = moderateViewExternal(embed.external, moderationOptions) const ui = mergeModUIResults( embedMod.ui('contentView'), embedMod.ui('contentMedia'), ) if (ui.blur) { // cover the card with the explanation from ui.blurs[0] if (ui.noOverride) { // don't allow the cover to be removed } } ``` `mergeModUIResults` accepts any number of `ModerationUI` values (undefined entries are skipped), combining their causes and preserving the strictest `noOverride`. ### Profile links A detailed profile (`app.bsky.actor.defs#profileViewDetailed`) can include `links`, a list of `profileLinkView`s. Each link can carry hydrated `labels`. Moderate each link separately with `moderateProfileLink`, in addition to (not instead of) the decision for the profile itself: ```typescript import { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation' const profileMod = moderateProfile(profile, moderationOptions) for (const link of profile.links ?? []) { const linkMod = moderateProfileLink(link, moderationOptions) if (linkMod.ui('contentView').blur) { // cover the link with the explanation from linkMod.ui('contentView').blurs[0] } if (linkMod.ui('contentMedia').blur) { // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0] } } ``` Notes: - Moderation is label-only. Link labels are treated as content labels, so use the `content*` UI contexts. `moderateProfileLink` does not include the profile's account or profile labels, blocks, mutes, or muted words, and `moderateProfile` does not include link labels. Combine decisions yourself where your UI needs to. - The link view has no owner profile. The owner is read from the DID in the link record's `uri` (never from the destination `url` or a label's source). This is what lets the owner's own self-labels apply and sets `isMe` when the viewer owns the link. If the `uri` is handle-based, no owner is inferred, though labels from subscribed labelers still apply. - `labels` on a `profileLinkView` are hydrated by the app view and may be absent, so treat a missing value as no labels. This is distinct from the `labels` self-label union that the record author sets on an `app.bsky.actor.link` record. ## Sending moderation reports Any Labeler is capable of receiving moderation reports. As a result, you need to specify which labeler should receive the report. You do this with the `service` option (the `atproto-proxy` header), overridable per request: ```typescript import { com } from '@bsky/sdk/lexicons' await client.call( com.atproto.moderation.createReport, { reasonType: com.atproto.moderation.defs.reasonViolation.$token, reason: 'They were being such a jerk to me!', subject: com.atproto.admin.defs.repoRef.$build({ did: 'did:web:bob.com', }), }, { service: 'did:web:my-labeler.com#atproto_labeler' }, ) ``` The Bluesky moderation service's address is available via the `api` export from the SDK: ```typescript import { api } from '@bsky/sdk' await client.call(com.atproto.moderation.createReport, report, { service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler }) ```