bsky Agent Skills & AI Rules

GitHub

TypeScript monorepo for Bluesky packages

14 stars TypeScript TypeScript 1 file ยท ~7,845 tokens
RAW Rules
4 Discovered Rules โ€ข ~14,965 Estimated Tokens
Quick:
Loading skill content...

Docs Skill

packages/sdk/docs/moderation.md anthropic-skill ~2,836 tokens
Raw
# 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<string, LabelPreference>
}

export interface ModerationPrefs {
  adultContentEnabled: boolean
  labels: Record<string, LabelPreference>
  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<string, InterpretedLabelValueDefinition[]>
}
```

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<string, InterpretedLabelValueDefinition[]> = {}
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
})
```