diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json
index c0dd41d31f2..b3cfe2e1967 100644
--- a/lexicons/app/bsky/actor/defs.json
+++ b/lexicons/app/bsky/actor/defs.json
@@ -288,20 +288,20 @@
}
}
},
- "modsPref": {
+ "labelersPref": {
"type": "object",
- "required": ["mods"],
+ "required": ["labelers"],
"properties": {
- "mods": {
+ "labelers": {
"type": "array",
"items": {
"type": "ref",
- "ref": "#modPrefItem"
+ "ref": "#labelerPrefItem"
}
}
}
},
- "modPrefItem": {
+ "labelerPrefItem": {
"type": "object",
"required": ["did"],
"properties": {
diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json
index dc6fe3f83fa..9b1a1196e01 100644
--- a/lexicons/com/atproto/label/defs.json
+++ b/lexicons/com/atproto/label/defs.json
@@ -96,6 +96,16 @@
"description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
"knownValues": ["content", "media", "none"]
},
+ "defaultSetting": {
+ "type": "string",
+ "description": "The default setting for this label.",
+ "knownValues": ["ignore", "warn", "hide"],
+ "default": "warn"
+ },
+ "adultOnly": {
+ "type": "boolean",
+ "description": "Does the user need to have adult content enabled in order to configure this label?"
+ },
"locales": {
"type": "array",
"items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
diff --git a/packages/api/README.md b/packages/api/README.md
index c5e66d70862..201bba29f67 100644
--- a/packages/api/README.md
+++ b/packages/api/README.md
@@ -178,87 +178,70 @@ console.log(rt3.graphemeLength) // => 1
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
-- Moderator labeling
+- Mutewords
+- Hidden posts
-For more information, see the [Moderation Documentation](./docs/moderation.md) or the associated [Labels Reference](./docs/labels.md).
+For more information, see the [Moderation Documentation](./docs/moderation.md).
```typescript
-import { moderatePost, moderateProfile } from '@atproto/api'
+import { moderatePost } from '@atproto/api'
+
+// First get the user's moderation prefs and their label definitions
+// =
+
+const prefs = await agent.getPreferences()
+const labelDefs = await agent.getLabelDefinitions(prefs)
// We call the appropriate moderation function for the content
// =
-const postMod = moderatePost(postView, getOpts())
-const profileMod = moderateProfile(profileView, getOpts())
+const postMod = moderatePost(postView, {
+ userDid: agent.session.did,
+ moderationPrefs: prefs.moderationPrefs,
+ labelDefs,
+})
// We then use the output to decide how to affect rendering
// =
-if (postMod.content.filter) {
- // don't render in feeds or similar
- // in contexts where this is disruptive (eg threads) you should ignore this and instead check blur
+// in feeds
+if (postMod.ui('contentList').filter) {
+ // don't include in feeds
}
-if (postMod.content.blur) {
- // render the whole object behind a cover (use postMod.content.cause to explain)
- if (postMod.content.noOverride) {
+if (postMod.ui('contentList').blur) {
+ // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)
+ if (postMod.ui('contentList').noOverride) {
// do not allow the cover the be removed
}
}
-if (postMod.content.alert) {
- // render a warning on the content (use postMod.content.cause to explain)
+if (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {
+ // render warnings on the post
+ // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs
}
-if (postMod.embed.blur) {
- // render the embedded media behind a cover (use postMod.embed.cause to explain)
- if (postMod.embed.noOverride) {
+
+// viewed directly
+if (postMod.ui('contentView').filter) {
+ // don't include in feeds
+}
+if (postMod.ui('contentView').blur) {
+ // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)
+ if (postMod.ui('contentView').noOverride) {
// do not allow the cover the be removed
}
}
-if (postMod.embed.alert) {
- // render a warning on the embedded media (use postMod.embed.cause to explain)
-}
-if (postMod.avatar.blur) {
- // render the avatar behind a cover
-}
-if (postMod.avatar.alert) {
- // render an alert on the avatar
+if (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {
+ // render warnings on the post
+ // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs
}
-// The options passed into `apply()` supply the user's preferences
-// =
-
-function getOpts() {
- return {
- // the logged-in user's DID
- userDid: 'did:plc:1234...',
-
- // is adult content allowed?
- adultContentEnabled: true,
-
- // the global label settings (used on self-labels)
- labels: {
- porn: 'hide',
- sexual: 'warn',
- nudity: 'ignore',
- // ...
- },
-
- // the per-labeler settings
- labelers: [
- {
- labeler: {
- did: '...',
- displayName: 'My mod service',
- },
- labels: {
- porn: 'hide',
- sexual: 'warn',
- nudity: 'ignore',
- // ...
- },
- },
- ],
+// post embeds in all contexts
+if (postMod.ui('contentMedia').blur) {
+ // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)
+ if (postMod.ui('contentMedia').noOverride) {
+ // do not allow the cover the be removed
}
}
```
diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json
index c29c44d5d1b..913ad4365c6 100644
--- a/packages/api/definitions/labels.json
+++ b/packages/api/definitions/labels.json
@@ -27,15 +27,6 @@
}
}
},
- {
- "identifier": "!no-promote",
- "configurable": false,
- "defaultSetting": "hide",
- "flags": ["no-self"],
- "severity": "none",
- "blurs": "none",
- "behaviors": {}
- },
{
"identifier": "!warn",
"configurable": false,
@@ -91,54 +82,6 @@
}
}
},
- {
- "identifier": "dmca-violation",
- "configurable": false,
- "defaultSetting": "hide",
- "flags": ["no-override", "no-self"],
- "severity": "none",
- "blurs": "content",
- "behaviors": {
- "account": {
- "profileList": "blur",
- "profileView": "blur",
- "contentList": "blur",
- "contentView": "blur"
- },
- "profile": {
- "profileList": "blur",
- "profileView": "blur"
- },
- "content": {
- "contentList": "blur",
- "contentView": "blur"
- }
- }
- },
- {
- "identifier": "doxxing",
- "configurable": false,
- "defaultSetting": "hide",
- "flags": ["no-override", "no-self"],
- "severity": "none",
- "blurs": "content",
- "behaviors": {
- "account": {
- "profileList": "blur",
- "profileView": "blur",
- "contentList": "blur",
- "contentView": "blur"
- },
- "profile": {
- "profileList": "blur",
- "profileView": "blur"
- },
- "content": {
- "contentList": "blur",
- "contentView": "blur"
- }
- }
- },
{
"identifier": "porn",
"configurable": true,
@@ -184,8 +127,8 @@
{
"identifier": "nudity",
"configurable": true,
- "defaultSetting": "warn",
- "flags": ["adult"],
+ "defaultSetting": "ignore",
+ "flags": [],
"severity": "none",
"blurs": "media",
"behaviors": {
@@ -203,7 +146,7 @@
}
},
{
- "identifier": "gore",
+ "identifier": "graphic-media",
"flags": ["adult"],
"configurable": true,
"defaultSetting": "warn",
diff --git a/packages/api/docs/labels.md b/packages/api/docs/labels.md
deleted file mode 100644
index 6d40b4f58ae..00000000000
--- a/packages/api/docs/labels.md
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-# Labels
-
-This document is a reference for the labels used in the SDK.
-
-**⚠️ Note**: These labels are still in development and may change over time. Not all are currently in use.
-
-## Key
-
-### Label Preferences
-
-The possible client interpretations for a label.
-
-- ignore
Do nothing with the label.
-- warn
Provide some form of warning on the content (see "On Warn" behavior).
-- hide
Remove the content from feeds and apply the warning when directly viewed.
-
-### Configurable?
-
-Non-configurable labels cannot have their preference changed by the user. If a label is not configurable, it must have only own supported preference.
-
-### Flags
-
-Additional behaviors which a label can adopt.
-
-- no-override
The user cannot click through any covering of content created by the label.
-- adult
The user must have adult content enabled to configure the label. If adult content is not enabled, the label must adopt the strictest preference.
-
-### On Warn
-
-The kind of UI behavior used when a warning must be applied.
-
-- blur
Hide all of the content behind an interstitial.
-- blur-media
Hide only the media within the content (ie images) behind an interstitial.
-- alert
Display a descriptive warning but do not hide the content.
-- null
Do nothing.
-
-## Label Behaviors
-
-
-
- ID |
- Configurable |
- Flags |
- On Warn |
-
-
- !hide |
- ❌ (undefined) |
- no-override, no-self |
- undefined |
-
-
- !no-promote |
- ❌ (undefined) |
- no-self |
- undefined |
-
-
- !warn |
- ❌ (undefined) |
- no-self |
- undefined |
-
-
- !no-unauthenticated |
- ❌ (undefined) |
- no-override, unauthed |
- undefined |
-
-
- dmca-violation |
- ❌ (undefined) |
- no-override, no-self |
- undefined |
-
-
- doxxing |
- ❌ (undefined) |
- no-override, no-self |
- undefined |
-
-
- porn |
- ✅ |
- adult |
- undefined |
-
-
- sexual |
- ✅ |
- adult |
- undefined |
-
-
- nudity |
- ✅ |
- adult |
- undefined |
-
-
- gore |
- ✅ |
- adult |
- undefined |
-
-
diff --git a/packages/api/docs/moderation.md b/packages/api/docs/moderation.md
index 7e8d09dd1aa..571660d00fa 100644
--- a/packages/api/docs/moderation.md
+++ b/packages/api/docs/moderation.md
@@ -2,15 +2,11 @@
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
-- Moderator labeling
-
-For more information, see the [Moderation Documentation](./docs/moderation.md) or the associated [Labels Reference](./docs/labels.md).
-
-Additional docs:
-
-- [Labels Reference](./labels.md)
+- Mutewords
+- Hidden posts
## Configuration
@@ -21,131 +17,244 @@ Every moderation function takes a set of options which look like this:
// the logged-in user's DID
userDid: 'did:plc:1234...',
- // is adult content allowed?
- adultContentEnabled: true,
+ moderationPrefs: {
+ // is adult content allowed?
+ adultContentEnabled: true,
- // the global label settings (used on self-labels)
- labels: {
- porn: 'hide',
- sexual: 'warn',
- nudity: 'ignore',
- // ...
- },
+ // the global label settings (used on self-labels)
+ labels: {
+ porn: 'hide',
+ sexual: 'warn',
+ nudity: 'ignore',
+ // ...
+ },
- // the per-labeler settings
- labelers: [
- {
- labeler: {
- did: '...',
- displayName: 'My mod service'
- },
- 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
-interface ModerationOpts {
- userDid: string
+export interface ModerationPrefsLabeler {
+ did: string
+ labels: Record
+}
+
+export interface ModerationPrefs {
adultContentEnabled: boolean
labels: Record
- labelers: LabelerSettings[]
+ labelers: ModerationPrefsLabeler[]
+ mutedWords: AppBskyActorDefs.MutedWord[]
+ hiddenPosts: string[]
}
-interface Labeler {
- did: string
- displayName: string
+export interface ModerationOpts {
+ userDid: string | undefined
+ prefs: ModerationPrefs
+ /**
+ * Map of labeler did -> custom definitions
+ */
+ labelDefs?: Record
}
+```
-type LabelPreference = 'ignore' | 'warn' | 'hide'
+You can quickly grab the `ModerationPrefs` using the `agent.getPreferences()` method:
-interface LabelerSettings {
- labeler: Labeler
- labels: Record
-}
+```typescript
+const prefs = await agent.getPreferences()
+moderatePost(post, {
+ userDid: /*...*/,
+ prefs: prefs.moderationPrefs,
+ labelDefs: /*...*/
+})
```
-## Posts
+To gather the label definitions (`labelDefs`) see the _Labelers_ section below.
-Applications need to produce the [Post Moderation Behaviors](./moderation-behaviors/posts.md) using the `moderatePost()` API.
+## 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 this static function, though the default is to use Bluesky's moderation:
```typescript
-import { moderatePost } from '@atproto/api'
+BskyAgent.configure({
+ appLabelers: ['did:web:my-labeler.com'],
+})
+```
-const postMod = moderatePost(postView, getOpts())
+Users may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.
-if (postMod.content.filter) {
- // don't render in feeds or similar
- // in contexts where this is disruptive (eg threads) you should ignore this and instead check blur
-}
-if (postMod.content.blur) {
- // render the whole object behind a cover (use postMod.content.cause to explain)
- if (postMod.content.noOverride) {
- // do not allow the cover the be removed
- }
-}
-if (postMod.content.alert) {
- // render a warning on the content (use postMod.content.cause to explain)
-}
-if (postMod.embed.blur) {
- // render the embedded media behind a cover (use postMod.embed.cause to explain)
- if (postMod.embed.noOverride) {
- // do not allow the cover the be removed
- }
-}
-if (postMod.embed.alert) {
- // render a warning on the embedded media (use postMod.embed.cause to explain)
-}
-if (postMod.avatar.blur) {
- // render the avatar behind a cover
-}
-if (postMod.avatar.alert) {
- // render an alert on the avatar
+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'
}
```
-## Profiles
+The label value definition 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.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).
+
+Here is how to do this:
+
+```typescript
+import { BskyAgent } from '@atproto/api'
+
+const agent = new BskyAgent()
+// assume `agent` is a signed in session
+const prefs = await agent.getPreferences()
+const labelDefs = await agent.getLabelDefinitions(prefs)
+
+moderatePost(post, {
+ userDid: agent.session.did,
+ 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,
+ moderateFeedGen,
+ moderateUserList,
+ moderateLabeler,
+} from '@atproto/api'
+```
+
+Each of these follows the same API signature:
+
+```typescript
+const res = moderatePost(post, moderationOptions)
+```
-Applications need to produce the [Profile Moderation Behaviors](./moderation-behaviors/profiles.md) using the `moderateProfile()` API.
+The response object provides an API for figuring out what your UI should do in different contexts.
```typescript
-import { moderateProfile } from '@atproto/api'
+res.ui(context) /* =>
-const profileMod = moderateProfile(profileView, getOpts())
+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?
-if (profileMod.account.filter) {
- // don't render in discovery
+ // the reasons for each of the flags:
+ filters: ModerationCause[]
+ blurs: ModerationCause[]
+ alerts: ModerationCause[]
+ informs: ModerationCause[]
}
-if (profileMod.account.blur) {
- // render the whole account behind a cover (use profileMod.account.cause to explain)
- if (profileMod.account.noOverride) {
- // do not allow the cover the be removed
- }
+*/
+```
+
+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 (profileMod.account.alert) {
- // render a warning on the account (use profileMod.account.cause to explain)
+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 (profileMod.profile.blur) {
- // render the profile information (display name, bio) behind a cover
- if (profileMod.profile.noOverride) {
- // do not allow the cover the be removed
+if (mod.ui('contentMedia').blur) {
+ // cover the post's embbedded images with the explanation from mod.ui('contentMedia').blurs[0]
+ if (mod.ui('contentMedia').noOverride) {
+ // dont allow the cover to be removed
}
}
-if (profileMod.profile.alert) {
- // render a warning on the profile (use profileMod.profile.cause to explain)
+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
+ }
}
-if (profileMod.avatar.blur) {
- // render the avatar behind a cover
+for (const alert of mod.ui('contentList').alerts) {
+ // render this alert
}
-if (profileMod.avatar.alert) {
- // render an alert on the avatar
+for (const inform of mod.ui('contentList').informs) {
+ // render this inform
}
```
+
+## 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 `Atproto-Proxy` header:
+
+```typescript
+agent
+ .withProxy('atproto_labeler', 'did:web:my-labeler.com')
+ .createModerationReport({
+ reasonType: 'com.atproto.moderation.defs#reasonViolation',
+ reason: 'They were being such a jerk to me!',
+ subject: { did: 'did:web:bob.com' },
+ })
+```
diff --git a/packages/api/package.json b/packages/api/package.json
index 0bdba638646..8a9e7b0a760 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -20,8 +20,7 @@
"types": "dist/index.d.ts"
},
"scripts": {
- "codegen": "pnpm docgen && node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/tools/ozone/*/*",
- "docgen": "node ./scripts/generate-docs.mjs",
+ "codegen": "node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/tools/ozone/*/*",
"build": "node ./build.js",
"postbuild": "tsc --build tsconfig.build.json",
"update-main-to-dist": "node ../../update-main-to-dist.js packages/api",
@@ -42,6 +41,7 @@
"devDependencies": {
"@atproto/lex-cli": "workspace:^",
"@atproto/dev-env": "workspace:^",
- "common-tags": "^1.8.2"
+ "common-tags": "^1.8.2",
+ "get-port": "^6.1.2"
}
}
diff --git a/packages/api/scripts/code/labels.mjs b/packages/api/scripts/code/labels.mjs
index 2bc8e93fdf0..274d7c30178 100644
--- a/packages/api/scripts/code/labels.mjs
+++ b/packages/api/scripts/code/labels.mjs
@@ -21,7 +21,7 @@ writeFileSync(
async function gen() {
return prettier.format(
`/** this doc is generated by ./scripts/code/labels.mjs **/
- import {InterprettedLabelValueDefinition, LabelPreference} from '../types'
+ import {InterpretedLabelValueDefinition, LabelPreference} from '../types'
export type KnownLabelValue = ${labelsDef
.map((label) => `"${label.identifier}"`)
@@ -35,7 +35,7 @@ async function gen() {
),
)}
- export const LABELS: Record = ${JSON.stringify(
+ export const LABELS: Record = ${JSON.stringify(
Object.fromEntries(
labelsDef.map((label) => [label.identifier, { ...label, locales: [] }]),
),
diff --git a/packages/api/scripts/docs/labels.mjs b/packages/api/scripts/docs/labels.mjs
deleted file mode 100644
index e8986b54030..00000000000
--- a/packages/api/scripts/docs/labels.mjs
+++ /dev/null
@@ -1,87 +0,0 @@
-import * as url from 'url'
-import { readFileSync, writeFileSync } from 'fs'
-import { join } from 'path'
-import { stripIndent } from 'common-tags'
-
-const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
-
-const labelsDef = JSON.parse(
- readFileSync(
- join(__dirname, '..', '..', 'definitions', 'labels.json'),
- 'utf8',
- ),
-)
-
-writeFileSync(join(__dirname, '..', '..', 'docs', 'labels.md'), doc(), 'utf8')
-
-function doc() {
- return stripIndent`
-
-
-# Labels
-
-This document is a reference for the labels used in the SDK.
-
-**⚠️ Note**: These labels are still in development and may change over time. Not all are currently in use.
-
-## Key
-
-### Label Preferences
-
-The possible client interpretations for a label.
-
-- ignore
Do nothing with the label.
-- warn
Provide some form of warning on the content (see "On Warn" behavior).
-- hide
Remove the content from feeds and apply the warning when directly viewed.
-
-### Configurable?
-
-Non-configurable labels cannot have their preference changed by the user. If a label is not configurable, it must have only own supported preference.
-
-### Flags
-
-Additional behaviors which a label can adopt.
-
-- no-override
The user cannot click through any covering of content created by the label.
-- adult
The user must have adult content enabled to configure the label. If adult content is not enabled, the label must adopt the strictest preference.
-
-### On Warn
-
-The kind of UI behavior used when a warning must be applied.
-
-- blur
Hide all of the content behind an interstitial.
-- blur-media
Hide only the media within the content (ie images) behind an interstitial.
-- alert
Display a descriptive warning but do not hide the content.
-- null
Do nothing.
-
-## Label Behaviors
-
-
-
- ID |
- Configurable |
- Flags |
- On Warn |
-
- ${labelsRef()}
-
`
-}
-
-function labelsRef() {
- const lines = []
- for (const label of labelsDef) {
- lines.push(stripIndent`
-
- ${label.identifier} |
- ${
- label.configurable ? '✅' : `❌ (${label.fixedPreference})`
- } |
- ${label.flags.join(', ')} |
- ${label.onwarn} |
-
- `)
- }
- return lines.join('\n')
-}
-
-export {}
diff --git a/packages/api/scripts/generate-docs.mjs b/packages/api/scripts/generate-docs.mjs
deleted file mode 100644
index 6259f745fad..00000000000
--- a/packages/api/scripts/generate-docs.mjs
+++ /dev/null
@@ -1,3 +0,0 @@
-import './docs/labels.mjs'
-
-export {}
diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts
index cfbd2d75c59..87cfd4bcfb6 100644
--- a/packages/api/src/agent.ts
+++ b/packages/api/src/agent.ts
@@ -17,9 +17,12 @@ import {
AtpAgentGlobalOpts,
AtpPersistSessionHandler,
AtpAgentOpts,
+ AtprotoServiceType,
} from './types'
-import { BSKY_MODSERVICE_DID } from './const'
+import { BSKY_LABELER_DID } from './const'
+const MAX_MOD_AUTHORITIES = 3
+const MAX_LABELERS = 10
const REFRESH_SESSION = 'com.atproto.server.refreshSession'
/**
@@ -30,16 +33,13 @@ export class AtpAgent {
service: URL
api: AtpServiceClient
session?: AtpSessionData
- labelersHeader: string[] = [BSKY_MODSERVICE_DID]
+ labelersHeader: string[] = []
+ proxyHeader: string | undefined
+ pdsUrl: URL | undefined // The PDS URL, driven by the did doc. May be undefined.
- /**
- * The PDS URL, driven by the did doc. May be undefined.
- */
- pdsUrl: URL | undefined
-
- private _baseClient: AtpBaseClient
- private _persistSession?: AtpPersistSessionHandler
- private _refreshSessionPromise: Promise | undefined
+ protected _baseClient: AtpBaseClient
+ protected _persistSession?: AtpPersistSessionHandler
+ protected _refreshSessionPromise: Promise | undefined
get com() {
return this.api.com
@@ -50,11 +50,21 @@ export class AtpAgent {
*/
static fetch: AtpAgentFetchHandler | undefined = defaultFetchHandler
+ /**
+ * The labelers to be used across all requests with the takedown capability
+ */
+ static appLabelers: string[] = [BSKY_LABELER_DID]
+
/**
* Configures the API globally.
*/
static configure(opts: AtpAgentGlobalOpts) {
- AtpAgent.fetch = opts.fetch
+ if (opts.fetch) {
+ AtpAgent.fetch = opts.fetch
+ }
+ if (opts.appLabelers) {
+ AtpAgent.appLabelers = opts.appLabelers
+ }
}
constructor(opts: AtpAgentOpts) {
@@ -68,6 +78,27 @@ export class AtpAgent {
this.api = this._baseClient.service(opts.service)
}
+ clone() {
+ const inst = new AtpAgent({
+ service: this.service,
+ })
+ this.copyInto(inst)
+ return inst
+ }
+
+ copyInto(inst: AtpAgent) {
+ inst.session = this.session
+ inst.labelersHeader = this.labelersHeader
+ inst.proxyHeader = this.proxyHeader
+ inst.pdsUrl = this.pdsUrl
+ }
+
+ withProxy(serviceType: AtprotoServiceType, did: string) {
+ const inst = this.clone()
+ inst.configureProxyHeader(serviceType, did)
+ return inst
+ }
+
/**
* Is there any active session?
*/
@@ -92,6 +123,15 @@ export class AtpAgent {
this.labelersHeader = labelerDids
}
+ /**
+ * Configures the atproto-proxy header to be applied on requests
+ */
+ configureProxyHeader(serviceType: AtprotoServiceType, did: string) {
+ if (did.startsWith('did:')) {
+ this.proxyHeader = `${did}#${serviceType}`
+ }
+ }
+
/**
* Create a new account and hydrate its session in this agent.
*/
@@ -212,15 +252,20 @@ export class AtpAgent {
authorization: `Bearer ${this.session.accessJwt}`,
}
}
- if (this.labelersHeader.length) {
+ if (this.proxyHeader) {
reqHeaders = {
...reqHeaders,
- 'atproto-labelers': this.labelersHeader
- .filter((str) => str.startsWith('did:'))
- .slice(0, 10)
- .join(','),
+ 'atproto-proxy': this.proxyHeader,
}
}
+ reqHeaders = {
+ ...reqHeaders,
+ 'atproto-accept-labelers': AtpAgent.appLabelers
+ .map((str) => `${str};redact`)
+ .concat(this.labelersHeader.filter((str) => str.startsWith('did:')))
+ .slice(0, MAX_LABELERS)
+ .join(', '),
+ }
return reqHeaders
}
diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts
index 57cc86cf8dd..e51347bca7e 100644
--- a/packages/api/src/bsky-agent.ts
+++ b/packages/api/src/bsky-agent.ts
@@ -4,6 +4,7 @@ import {
AppBskyFeedPost,
AppBskyActorProfile,
AppBskyActorDefs,
+ AppBskyLabelerDefs,
ComAtprotoRepoPutRecord,
} from './client'
import {
@@ -12,10 +13,14 @@ import {
BskyThreadViewPreference,
BskyInterestsPreference,
} from './types'
-import { LabelPreference } from './moderation/types'
-import { BSKY_MODSERVICE_DID } from './const'
+import {
+ InterpretedLabelValueDefinition,
+ LabelPreference,
+ ModerationPrefs,
+} from './moderation/types'
import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels'
import { sanitizeMutedWordValue } from './util'
+import { interpretLabelValueDefinitions } from './moderation'
const FEED_VIEW_PREF_DEFAULTS = {
hideReplies: false,
@@ -39,6 +44,14 @@ declare global {
}
export class BskyAgent extends AtpAgent {
+ clone() {
+ const inst = new BskyAgent({
+ service: this.service,
+ })
+ this.copyInto(inst)
+ return inst
+ }
+
get app() {
return this.api.app
}
@@ -103,6 +116,37 @@ export class BskyAgent extends AtpAgent {
getLabelers: typeof this.api.app.bsky.labeler.getServices = (params, opts) =>
this.api.app.bsky.labeler.getServices(params, opts)
+ async getLabelDefinitions(
+ prefs: BskyPreferences | ModerationPrefs | string[],
+ ): Promise> {
+ // collect the labeler dids
+ let dids: string[] = BskyAgent.appLabelers
+ if (isBskyPrefs(prefs)) {
+ dids = dids.concat(prefs.moderationPrefs.labelers.map((l) => l.did))
+ } else if (isModPrefs(prefs)) {
+ dids = dids.concat(prefs.labelers.map((l) => l.did))
+ } else {
+ dids = dids.concat(prefs)
+ }
+
+ // fetch their definitions
+ const labelers = await this.getLabelers({
+ dids,
+ detailed: true,
+ })
+
+ // assemble a map of labeler dids to the interpretted label value definitions
+ const labelDefs = {}
+ if (labelers.data) {
+ for (const labeler of labelers.data
+ .views as AppBskyLabelerDefs.LabelerViewDetailed[]) {
+ labelDefs[labeler.creator.did] = interpretLabelValueDefinitions(labeler)
+ }
+ }
+
+ return labelDefs
+ }
+
async post(
record: Partial &
Omit,
@@ -330,14 +374,14 @@ export class BskyAgent extends AtpAgent {
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS },
- mods: [],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
}
const res = await this.app.bsky.actor.getPreferences({})
const labelPrefs: AppBskyActorDefs.ContentLabelPref[] = []
@@ -356,12 +400,12 @@ export class BskyAgent extends AtpAgent {
const adjustedPref = adjustLegacyContentLabelPref(pref)
labelPrefs.push(adjustedPref)
} else if (
- AppBskyActorDefs.isModsPref(pref) &&
- AppBskyActorDefs.validateModsPref(pref).success
+ AppBskyActorDefs.isLabelersPref(pref) &&
+ AppBskyActorDefs.validateLabelersPref(pref).success
) {
- // mods preferences
- prefs.moderationPrefs.mods = pref.mods.map((mod) => ({
- ...mod,
+ // labelers preferences
+ prefs.moderationPrefs.labelers = pref.labelers.map((labeler) => ({
+ ...labeler,
labels: {},
}))
} else if (
@@ -408,42 +452,35 @@ export class BskyAgent extends AtpAgent {
) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
- prefs.mutedWords = v.items
+ prefs.moderationPrefs.mutedWords = v.items
} else if (
AppBskyActorDefs.isHiddenPostsPref(pref) &&
AppBskyActorDefs.validateHiddenPostsPref(pref).success
) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
- prefs.hiddenPosts = v.items
+ prefs.moderationPrefs.hiddenPosts = v.items
}
}
- // ensure the bluesky moderation is configured
- const bskyModeration = prefs.moderationPrefs.mods.find(
- (modPref) => modPref.did === BSKY_MODSERVICE_DID,
- )
- if (!bskyModeration) {
- prefs.moderationPrefs.mods.unshift({
- did: BSKY_MODSERVICE_DID,
- labels: {},
- })
- }
-
// apply the label prefs
for (const pref of labelPrefs) {
if (pref.labelerDid) {
- const mod = prefs.moderationPrefs.mods.find(
- (mod) => mod.did === pref.labelerDid,
+ const labeler = prefs.moderationPrefs.labelers.find(
+ (labeler) => labeler.did === pref.labelerDid,
)
- if (!mod) continue
- mod.labels[pref.label] = pref.visibility as LabelPreference
+ if (!labeler) continue
+ labeler.labels[pref.label] = pref.visibility as LabelPreference
} else {
prefs.moderationPrefs.labels[pref.label] =
pref.visibility as LabelPreference
}
}
+ prefs.moderationPrefs.labels = remapLegacyLabels(
+ prefs.moderationPrefs.labels,
+ )
+
// automatically configure the client
this.configureLabelersHeader(prefsArrayToLabelerDids(res.data.preferences))
@@ -522,6 +559,8 @@ export class BskyAgent extends AtpAgent {
pref.label === key &&
pref.labelerDid === labelerDid,
)
+ let legacyLabelPref: AppBskyActorDefs.ContentLabelPref | undefined
+
if (labelPref) {
labelPref.visibility = value
} else {
@@ -532,6 +571,40 @@ export class BskyAgent extends AtpAgent {
visibility: value,
}
}
+
+ if (AppBskyActorDefs.isContentLabelPref(labelPref)) {
+ // is global
+ if (!labelPref.labelerDid) {
+ const legacyLabelValue = {
+ 'graphic-media': 'gore',
+ porn: 'nsfw',
+ sexual: 'suggestive',
+ }[labelPref.label]
+
+ // if it's a legacy label, double-write the legacy label
+ if (legacyLabelValue) {
+ legacyLabelPref = prefs.findLast(
+ (pref) =>
+ AppBskyActorDefs.isContentLabelPref(pref) &&
+ AppBskyActorDefs.validateContentLabelPref(pref).success &&
+ pref.label === legacyLabelValue &&
+ pref.labelerDid === undefined,
+ ) as AppBskyActorDefs.ContentLabelPref | undefined
+
+ if (legacyLabelPref) {
+ legacyLabelPref.visibility = value
+ } else {
+ legacyLabelPref = {
+ $type: 'app.bsky.actor.defs#contentLabelPref',
+ label: legacyLabelValue,
+ labelerDid: undefined,
+ visibility: value,
+ }
+ }
+ }
+ }
+ }
+
return prefs
.filter(
(pref) =>
@@ -539,63 +612,78 @@ export class BskyAgent extends AtpAgent {
!(pref.label === key && pref.labelerDid === labelerDid),
)
.concat([labelPref])
+ .filter((pref) => {
+ if (!legacyLabelPref) return true
+ return (
+ !AppBskyActorDefs.isContentLabelPref(pref) ||
+ !(
+ pref.label === legacyLabelPref.label &&
+ pref.labelerDid === undefined
+ )
+ )
+ })
+ .concat(legacyLabelPref ? [legacyLabelPref] : [])
})
}
- async addModService(did: string) {
+ async addLabeler(did: string) {
const prefs = await updatePreferences(
this,
(prefs: AppBskyActorDefs.Preferences) => {
- let modsPref = prefs.findLast(
+ let labelersPref = prefs.findLast(
(pref) =>
- AppBskyActorDefs.isModsPref(pref) &&
- AppBskyActorDefs.validateModsPref(pref).success,
+ AppBskyActorDefs.isLabelersPref(pref) &&
+ AppBskyActorDefs.validateLabelersPref(pref).success,
)
- if (!modsPref) {
- modsPref = {
- $type: 'app.bsky.actor.defs#modsPref',
- mods: [],
+ if (!labelersPref) {
+ labelersPref = {
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [],
}
}
- if (AppBskyActorDefs.isModsPref(modsPref)) {
- let modPrefItem = modsPref.mods.find((mod) => mod.did === did)
- if (!modPrefItem) {
- modPrefItem = {
+ if (AppBskyActorDefs.isLabelersPref(labelersPref)) {
+ let labelerPrefItem = labelersPref.labelers.find(
+ (labeler) => labeler.did === did,
+ )
+ if (!labelerPrefItem) {
+ labelerPrefItem = {
did,
}
- modsPref.mods.push(modPrefItem)
+ labelersPref.labelers.push(labelerPrefItem)
}
}
return prefs
- .filter((pref) => !AppBskyActorDefs.isModsPref(pref))
- .concat([modsPref])
+ .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref))
+ .concat([labelersPref])
},
)
// automatically configure the client
this.configureLabelersHeader(prefsArrayToLabelerDids(prefs))
}
- async removeModService(did: string) {
+ async removeLabeler(did: string) {
const prefs = await updatePreferences(
this,
(prefs: AppBskyActorDefs.Preferences) => {
- let modsPref = prefs.findLast(
+ let labelersPref = prefs.findLast(
(pref) =>
- AppBskyActorDefs.isModsPref(pref) &&
- AppBskyActorDefs.validateModsPref(pref).success,
+ AppBskyActorDefs.isLabelersPref(pref) &&
+ AppBskyActorDefs.validateLabelersPref(pref).success,
)
- if (!modsPref) {
- modsPref = {
- $type: 'app.bsky.actor.defs#modsPref',
- mods: [],
+ if (!labelersPref) {
+ labelersPref = {
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [],
}
}
- if (AppBskyActorDefs.isModsPref(modsPref)) {
- modsPref.mods = modsPref.mods.filter((mod) => mod.did !== did)
+ if (AppBskyActorDefs.isLabelersPref(labelersPref)) {
+ labelersPref.labelers = labelersPref.labelers.filter(
+ (labeler) => labeler.did !== did,
+ )
}
return prefs
- .filter((pref) => !AppBskyActorDefs.isModsPref(pref))
- .concat([modsPref])
+ .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref))
+ .concat([labelersPref])
},
)
// automatically configure the client
@@ -859,7 +947,6 @@ async function updateFeedPreferences(
function adjustLegacyContentLabelPref(
pref: AppBskyActorDefs.ContentLabelPref,
): AppBskyActorDefs.ContentLabelPref {
- let label = pref.label
let visibility = pref.visibility
// adjust legacy values
@@ -867,15 +954,31 @@ function adjustLegacyContentLabelPref(
visibility = 'ignore'
}
- // adjust legacy labels
- if (label === 'nsfw') {
- label = 'porn'
+ return { ...pref, visibility }
+}
+
+/**
+ * Re-maps legacy labels to new labels on READ. Does not save these changes to
+ * the user's preferences.
+ */
+function remapLegacyLabels(
+ labels: BskyPreferences['moderationPrefs']['labels'],
+) {
+ const _labels = { ...labels }
+ const legacyToNewMap: Record = {
+ gore: 'graphic-media',
+ nsfw: 'porn',
+ suggestive: 'sexual',
}
- if (label === 'suggestive') {
- label = 'sexual'
+
+ for (const labelName in _labels) {
+ const newLabelName = legacyToNewMap[labelName]!
+ if (newLabelName) {
+ _labels[newLabelName] = _labels[labelName]
+ }
}
- return { ...pref, label, visibility }
+ return _labels
}
/**
@@ -884,17 +987,16 @@ function adjustLegacyContentLabelPref(
function prefsArrayToLabelerDids(
prefs: AppBskyActorDefs.Preferences,
): string[] {
- const modsPref = prefs.findLast(
+ const labelersPref = prefs.findLast(
(pref) =>
- AppBskyActorDefs.isModsPref(pref) &&
- AppBskyActorDefs.validateModsPref(pref).success,
+ AppBskyActorDefs.isLabelersPref(pref) &&
+ AppBskyActorDefs.validateLabelersPref(pref).success,
)
let dids: string[] = []
- if (modsPref) {
- dids = (modsPref as AppBskyActorDefs.ModsPref).mods.map((mod) => mod.did)
- }
- if (!dids.includes(BSKY_MODSERVICE_DID)) {
- dids.unshift(BSKY_MODSERVICE_DID)
+ if (labelersPref) {
+ dids = (labelersPref as AppBskyActorDefs.LabelersPref).labelers.map(
+ (labeler) => labeler.did,
+ )
}
return dids
}
@@ -928,3 +1030,16 @@ async function updateHiddenPost(
.concat([{ ...pref, $type: 'app.bsky.actor.defs#hiddenPostsPref' }])
})
}
+
+function isBskyPrefs(v: any): v is BskyPreferences {
+ return (
+ v &&
+ typeof v === 'object' &&
+ 'moderationPrefs' in v &&
+ isModPrefs(v.moderationPrefs)
+ )
+}
+
+function isModPrefs(v: any): v is ModerationPrefs {
+ return v && typeof v === 'object' && 'labelers' in v
+}
diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts
index 5a45c31b922..c1f266c1ecf 100644
--- a/packages/api/src/client/lexicons.ts
+++ b/packages/api/src/client/lexicons.ts
@@ -855,6 +855,17 @@ export const schemaDict = {
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
knownValues: ['content', 'media', 'none'],
},
+ defaultSetting: {
+ type: 'string',
+ description: 'The default setting for this label.',
+ knownValues: ['ignore', 'warn', 'hide'],
+ default: 'warn',
+ },
+ adultOnly: {
+ type: 'boolean',
+ description:
+ 'Does the user need to have adult content enabled in order to configure this label?',
+ },
locales: {
type: 'array',
items: {
@@ -3958,20 +3969,20 @@ export const schemaDict = {
},
},
},
- modsPref: {
+ labelersPref: {
type: 'object',
- required: ['mods'],
+ required: ['labelers'],
properties: {
- mods: {
+ labelers: {
type: 'array',
items: {
type: 'ref',
- ref: 'lex:app.bsky.actor.defs#modPrefItem',
+ ref: 'lex:app.bsky.actor.defs#labelerPrefItem',
},
},
},
},
- modPrefItem: {
+ labelerPrefItem: {
type: 'object',
required: ['did'],
properties: {
diff --git a/packages/api/src/client/types/app/bsky/actor/defs.ts b/packages/api/src/client/types/app/bsky/actor/defs.ts
index 7eebedc47f4..4243002b862 100644
--- a/packages/api/src/client/types/app/bsky/actor/defs.ts
+++ b/packages/api/src/client/types/app/bsky/actor/defs.ts
@@ -338,36 +338,36 @@ export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
-export interface ModsPref {
- mods: ModPrefItem[]
+export interface LabelersPref {
+ labelers: LabelerPrefItem[]
[k: string]: unknown
}
-export function isModsPref(v: unknown): v is ModsPref {
+export function isLabelersPref(v: unknown): v is LabelersPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modsPref'
+ v.$type === 'app.bsky.actor.defs#labelersPref'
)
}
-export function validateModsPref(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modsPref', v)
+export function validateLabelersPref(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelersPref', v)
}
-export interface ModPrefItem {
+export interface LabelerPrefItem {
did: string
[k: string]: unknown
}
-export function isModPrefItem(v: unknown): v is ModPrefItem {
+export function isLabelerPrefItem(v: unknown): v is LabelerPrefItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modPrefItem'
+ v.$type === 'app.bsky.actor.defs#labelerPrefItem'
)
}
-export function validateModPrefItem(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modPrefItem', v)
+export function validateLabelerPrefItem(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelerPrefItem', v)
}
diff --git a/packages/api/src/client/types/com/atproto/label/defs.ts b/packages/api/src/client/types/com/atproto/label/defs.ts
index cfa5bb648b2..34009a39b03 100644
--- a/packages/api/src/client/types/com/atproto/label/defs.ts
+++ b/packages/api/src/client/types/com/atproto/label/defs.ts
@@ -86,6 +86,10 @@ export interface LabelValueDefinition {
severity: 'inform' | 'alert' | 'none' | (string & {})
/** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */
blurs: 'content' | 'media' | 'none' | (string & {})
+ /** The default setting for this label. */
+ defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {})
+ /** Does the user need to have adult content enabled in order to configure this label? */
+ adultOnly?: boolean
locales: LabelValueDefinitionStrings[]
[k: string]: unknown
}
diff --git a/packages/api/src/const.ts b/packages/api/src/const.ts
index 1513c9d1ef9..7575c55d3a9 100644
--- a/packages/api/src/const.ts
+++ b/packages/api/src/const.ts
@@ -1 +1 @@
-export const BSKY_MODSERVICE_DID = 'did:plc:ar7c4by46qjdydhdevvrndac'
+export const BSKY_LABELER_DID = 'did:plc:ar7c4by46qjdydhdevvrndac'
diff --git a/packages/api/src/mocker.ts b/packages/api/src/mocker.ts
index d608c8a1abe..556dba965c8 100644
--- a/packages/api/src/mocker.ts
+++ b/packages/api/src/mocker.ts
@@ -13,16 +13,19 @@ const FAKE_CID = 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq'
export const mock = {
post({
text,
+ facets,
reply,
embed,
}: {
text: string
+ facets?: AppBskyFeedPost.Record['facets']
reply?: AppBskyFeedPost.ReplyRef
embed?: AppBskyFeedPost.Record['embed']
}): AppBskyFeedPost.Record {
return {
$type: 'app.bsky.feed.post',
text,
+ facets,
reply,
embed,
langs: ['en'],
@@ -50,6 +53,7 @@ export const mock = {
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyFeedDefs.PostView {
return {
+ $type: 'app.bsky.feed.defs#postView',
uri: `at://${author.did}/app.bsky.feed.post/fake`,
cid: FAKE_CID,
author,
diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts
index 624bb5fa395..53be497191f 100644
--- a/packages/api/src/moderation/const/labels.ts
+++ b/packages/api/src/moderation/const/labels.ts
@@ -1,26 +1,23 @@
/** this doc is generated by ./scripts/code/labels.mjs **/
-import { InterprettedLabelValueDefinition, LabelPreference } from '../types'
+import { InterpretedLabelValueDefinition, LabelPreference } from '../types'
export type KnownLabelValue =
| '!hide'
- | '!no-promote'
| '!warn'
| '!no-unauthenticated'
- | 'dmca-violation'
- | 'doxxing'
| 'porn'
| 'sexual'
| 'nudity'
- | 'gore'
+ | 'graphic-media'
export const DEFAULT_LABEL_SETTINGS: Record = {
porn: 'hide',
sexual: 'warn',
- nudity: 'warn',
- gore: 'warn',
+ nudity: 'ignore',
+ 'graphic-media': 'warn',
}
-export const LABELS: Record =
+export const LABELS: Record =
{
'!hide': {
identifier: '!hide',
@@ -51,16 +48,6 @@ export const LABELS: Record =
},
locales: [],
},
- '!no-promote': {
- identifier: '!no-promote',
- configurable: false,
- defaultSetting: 'hide',
- flags: ['no-self'],
- severity: 'none',
- blurs: 'none',
- behaviors: {},
- locales: [],
- },
'!warn': {
identifier: '!warn',
configurable: false,
@@ -118,56 +105,6 @@ export const LABELS: Record =
},
locales: [],
},
- 'dmca-violation': {
- identifier: 'dmca-violation',
- configurable: false,
- defaultSetting: 'hide',
- flags: ['no-override', 'no-self'],
- severity: 'none',
- blurs: 'content',
- behaviors: {
- account: {
- profileList: 'blur',
- profileView: 'blur',
- contentList: 'blur',
- contentView: 'blur',
- },
- profile: {
- profileList: 'blur',
- profileView: 'blur',
- },
- content: {
- contentList: 'blur',
- contentView: 'blur',
- },
- },
- locales: [],
- },
- doxxing: {
- identifier: 'doxxing',
- configurable: false,
- defaultSetting: 'hide',
- flags: ['no-override', 'no-self'],
- severity: 'none',
- blurs: 'content',
- behaviors: {
- account: {
- profileList: 'blur',
- profileView: 'blur',
- contentList: 'blur',
- contentView: 'blur',
- },
- profile: {
- profileList: 'blur',
- profileView: 'blur',
- },
- content: {
- contentList: 'blur',
- contentView: 'blur',
- },
- },
- locales: [],
- },
porn: {
identifier: 'porn',
configurable: true,
@@ -215,8 +152,8 @@ export const LABELS: Record =
nudity: {
identifier: 'nudity',
configurable: true,
- defaultSetting: 'warn',
- flags: ['adult'],
+ defaultSetting: 'ignore',
+ flags: [],
severity: 'none',
blurs: 'media',
behaviors: {
@@ -234,8 +171,8 @@ export const LABELS: Record =
},
locales: [],
},
- gore: {
- identifier: 'gore',
+ 'graphic-media': {
+ identifier: 'graphic-media',
flags: ['adult'],
configurable: true,
defaultSetting: 'warn',
diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts
index 58ce07615b8..b8a76206e67 100644
--- a/packages/api/src/moderation/decision.ts
+++ b/packages/api/src/moderation/decision.ts
@@ -2,13 +2,13 @@ import { AppBskyGraphDefs } from '../client/index'
import {
BLOCK_BEHAVIOR,
MUTE_BEHAVIOR,
+ MUTEWORD_BEHAVIOR,
HIDE_BEHAVIOR,
NOOP_BEHAVIOR,
Label,
LabelPreference,
ModerationCause,
ModerationOpts,
- InterprettedLabelValueDefinition,
LabelTarget,
ModerationBehavior,
CUSTOM_LABEL_VALUE_RE,
@@ -32,18 +32,25 @@ export class ModerationDecision {
static merge(
...decisions: (ModerationDecision | undefined)[]
): ModerationDecision {
- const firmDecisions: ModerationDecision[] = decisions.filter(
+ const decisionsFiltered: ModerationDecision[] = decisions.filter(
(v) => !!v,
) as ModerationDecision[]
const decision = new ModerationDecision()
- if (firmDecisions[0]) {
- decision.did = firmDecisions[0].did
- decision.isMe = firmDecisions[0].isMe
+ if (decisionsFiltered[0]) {
+ decision.did = decisionsFiltered[0].did
+ decision.isMe = decisionsFiltered[0].isMe
}
- decision.causes = firmDecisions.flatMap((d) => d.causes)
+ decision.causes = decisionsFiltered.flatMap((d) => d.causes)
return decision
}
+ downgrade() {
+ for (const cause of this.causes) {
+ cause.downgraded = true
+ }
+ return this
+ }
+
get blocked() {
return !!this.blockCause
}
@@ -83,51 +90,79 @@ export class ModerationDecision {
if (context === 'profileList' || context === 'contentList') {
ui.filters.push(cause)
}
- if (BLOCK_BEHAVIOR[context] === 'blur') {
- ui.noOverride = true
- ui.blurs.push(cause)
- } else if (BLOCK_BEHAVIOR[context] === 'alert') {
- ui.alerts.push(cause)
- } else if (BLOCK_BEHAVIOR[context] === 'inform') {
- ui.informs.push(cause)
+ if (!cause.downgraded) {
+ if (BLOCK_BEHAVIOR[context] === 'blur') {
+ ui.noOverride = true
+ ui.blurs.push(cause)
+ } else if (BLOCK_BEHAVIOR[context] === 'alert') {
+ ui.alerts.push(cause)
+ } else if (BLOCK_BEHAVIOR[context] === 'inform') {
+ ui.informs.push(cause)
+ }
}
} else if (cause.type === 'muted') {
if (context === 'profileList' || context === 'contentList') {
ui.filters.push(cause)
}
- if (MUTE_BEHAVIOR[context] === 'blur') {
- ui.blurs.push(cause)
- } else if (MUTE_BEHAVIOR[context] === 'alert') {
- ui.alerts.push(cause)
- } else if (MUTE_BEHAVIOR[context] === 'inform') {
- ui.informs.push(cause)
+ if (!cause.downgraded) {
+ if (MUTE_BEHAVIOR[context] === 'blur') {
+ ui.blurs.push(cause)
+ } else if (MUTE_BEHAVIOR[context] === 'alert') {
+ ui.alerts.push(cause)
+ } else if (MUTE_BEHAVIOR[context] === 'inform') {
+ ui.informs.push(cause)
+ }
+ }
+ } else if (cause.type === 'mute-word') {
+ if (context === 'contentList') {
+ ui.filters.push(cause)
+ }
+ if (!cause.downgraded) {
+ if (MUTEWORD_BEHAVIOR[context] === 'blur') {
+ ui.blurs.push(cause)
+ } else if (MUTEWORD_BEHAVIOR[context] === 'alert') {
+ ui.alerts.push(cause)
+ } else if (MUTEWORD_BEHAVIOR[context] === 'inform') {
+ ui.informs.push(cause)
+ }
}
} else if (cause.type === 'hidden') {
if (context === 'profileList' || context === 'contentList') {
ui.filters.push(cause)
}
- if (HIDE_BEHAVIOR[context] === 'blur') {
- ui.blurs.push(cause)
- } else if (HIDE_BEHAVIOR[context] === 'alert') {
- ui.alerts.push(cause)
- } else if (HIDE_BEHAVIOR[context] === 'inform') {
- ui.informs.push(cause)
+ if (!cause.downgraded) {
+ if (HIDE_BEHAVIOR[context] === 'blur') {
+ ui.blurs.push(cause)
+ } else if (HIDE_BEHAVIOR[context] === 'alert') {
+ ui.alerts.push(cause)
+ } else if (HIDE_BEHAVIOR[context] === 'inform') {
+ ui.informs.push(cause)
+ }
}
} else if (cause.type === 'label') {
- if (context === 'profileList' || context === 'contentList') {
+ if (context === 'profileList' && cause.target === 'account') {
+ if (cause.setting === 'hide') {
+ ui.filters.push(cause)
+ }
+ } else if (
+ context === 'contentList' &&
+ (cause.target === 'account' || cause.target === 'content')
+ ) {
if (cause.setting === 'hide') {
ui.filters.push(cause)
}
}
- if (cause.behavior[context] === 'blur') {
- ui.blurs.push(cause)
- if (cause.noOverride) {
- ui.noOverride = true
+ if (!cause.downgraded) {
+ if (cause.behavior[context] === 'blur') {
+ ui.blurs.push(cause)
+ if (cause.noOverride) {
+ ui.noOverride = true
+ }
+ } else if (cause.behavior[context] === 'alert') {
+ ui.alerts.push(cause)
+ } else if (cause.behavior[context] === 'inform') {
+ ui.informs.push(cause)
}
- } else if (cause.behavior[context] === 'alert') {
- ui.alerts.push(cause)
- } else if (cause.behavior[context] === 'inform') {
- ui.informs.push(cause)
}
}
}
@@ -156,6 +191,16 @@ export class ModerationDecision {
}
}
+ addMutedWord(mutedWord: boolean) {
+ if (mutedWord) {
+ this.causes.push({
+ type: 'mute-word',
+ source: { type: 'user' },
+ priority: 6,
+ })
+ }
+ }
+
addBlocking(blocking: string | undefined) {
if (blocking) {
this.causes.push({
@@ -214,7 +259,7 @@ export class ModerationDecision {
const isSelf = label.src === this.did
const labeler = isSelf
? undefined
- : opts.prefs.mods.find((s) => s.did === label.src)
+ : opts.prefs.labelers.find((s) => s.did === label.src)
if (!isSelf && !labeler) {
return // skip labelers not configured by the user
@@ -224,7 +269,7 @@ export class ModerationDecision {
}
// establish the label preference for interpretation
- let labelPref: LabelPreference = 'ignore'
+ let labelPref: LabelPreference = labelDef.defaultSetting || 'ignore'
if (!labelDef.configurable) {
labelPref = labelDef.defaultSetting || 'hide'
} else if (
@@ -289,6 +334,7 @@ export class ModerationDecision {
: { type: 'labeler', did: labeler.did },
label,
labelDef,
+ target,
setting: labelPref,
behavior: labelDef.behaviors[target] || NOOP_BEHAVIOR,
noOverride,
diff --git a/packages/api/src/moderation/index.ts b/packages/api/src/moderation/index.ts
index 2b7a1e9164c..503e635c816 100644
--- a/packages/api/src/moderation/index.ts
+++ b/packages/api/src/moderation/index.ts
@@ -1,4 +1,3 @@
-import { AppBskyActorDefs } from '../client/index'
import {
ModerationSubjectProfile,
ModerationSubjectPost,
@@ -17,6 +16,7 @@ import { ModerationDecision } from './decision'
export { ModerationUI } from './ui'
export { ModerationDecision } from './decision'
+export { hasMutedWord } from './mutewords'
export {
interpretLabelValueDefinition,
interpretLabelValueDefinitions,
@@ -36,45 +36,26 @@ export function moderatePost(
subject: ModerationSubjectPost,
opts: ModerationOpts,
): ModerationDecision {
- return ModerationDecision.merge(
- decidePost(subject, opts),
- decideAccount(subject.author, opts),
- decideProfile(subject.author, opts),
- )
+ return decidePost(subject, opts)
}
export function moderateNotification(
subject: ModerationSubjectNotification,
opts: ModerationOpts,
): ModerationDecision {
- return ModerationDecision.merge(
- decideNotification(subject, opts),
- decideAccount(subject.author, opts),
- decideProfile(subject.author, opts),
- )
+ return decideNotification(subject, opts)
}
export function moderateFeedGenerator(
subject: ModerationSubjectFeedGenerator,
opts: ModerationOpts,
): ModerationDecision {
- return ModerationDecision.merge(
- decideFeedGenerator(subject, opts),
- decideAccount(subject.creator, opts),
- decideProfile(subject.creator, opts),
- )
+ return decideFeedGenerator(subject, opts)
}
export function moderateUserList(
subject: ModerationSubjectUserList,
opts: ModerationOpts,
): ModerationDecision {
- const userList = decideUserList(subject, opts)
- const account = AppBskyActorDefs.isProfileViewBasic(subject.creator)
- ? decideAccount(subject.creator, opts)
- : new ModerationDecision()
- const profile = AppBskyActorDefs.isProfileViewBasic(subject.creator)
- ? decideProfile(subject.creator, opts)
- : new ModerationDecision()
- return ModerationDecision.merge(userList, account, profile)
+ return decideUserList(subject, opts)
}
diff --git a/packages/api/src/moderation/mutewords.ts b/packages/api/src/moderation/mutewords.ts
new file mode 100644
index 00000000000..a4df492382c
--- /dev/null
+++ b/packages/api/src/moderation/mutewords.ts
@@ -0,0 +1,125 @@
+import { AppBskyActorDefs, AppBskyRichtextFacet } from '../client'
+
+const REGEX = {
+ LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
+ ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
+ // @TODO tidy this
+ // eslint-disable-next-line no-useless-escape
+ SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
+ WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
+}
+
+/**
+ * List of 2-letter lang codes for languages that either don't use spaces, or
+ * don't use spaces in a way conducive to word-based filtering.
+ *
+ * For these, we use a simple `String.includes` to check for a match.
+ */
+const LANGUAGE_EXCEPTIONS = [
+ 'ja', // Japanese
+ 'zh', // Chinese
+ 'ko', // Korean
+ 'th', // Thai
+ 'vi', // Vietnamese
+]
+
+export function hasMutedWord({
+ mutedWords,
+ text,
+ facets,
+ outlineTags,
+ languages,
+}: {
+ mutedWords: AppBskyActorDefs.MutedWord[]
+ text: string
+ facets?: AppBskyRichtextFacet.Main[]
+ outlineTags?: string[]
+ languages?: string[]
+}) {
+ const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
+ const tags = ([] as string[])
+ .concat(outlineTags || [])
+ .concat(
+ facets
+ ?.filter((facet) => {
+ return facet.features.find((feature) =>
+ AppBskyRichtextFacet.isTag(feature),
+ )
+ })
+ .map((t) => t.features[0].tag as string) || [],
+ )
+ .map((t) => t.toLowerCase())
+
+ for (const mute of mutedWords) {
+ const mutedWord = mute.value.toLowerCase()
+ const postText = text.toLowerCase()
+
+ // `content` applies to tags as well
+ if (tags.includes(mutedWord)) return true
+ // rest of the checks are for `content` only
+ if (!mute.targets.includes('content')) continue
+ // single character or other exception, has to use includes
+ if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
+ return true
+ // too long
+ if (mutedWord.length > postText.length) continue
+ // exact match
+ if (mutedWord === postText) return true
+ // any muted phrase with space or punctuation
+ if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
+ return true
+
+ // check individual character groups
+ const words = postText.split(REGEX.WORD_BOUNDARY)
+ for (const word of words) {
+ if (word === mutedWord) return true
+
+ // compare word without leading/trailing punctuation, but allow internal
+ // punctuation (such as `s@ssy`)
+ const wordTrimmedPunctuation = word.replace(
+ REGEX.LEADING_TRAILING_PUNCTUATION,
+ '',
+ )
+
+ if (mutedWord === wordTrimmedPunctuation) return true
+ if (mutedWord.length > wordTrimmedPunctuation.length) continue
+
+ // handle hyphenated, slash separated words, etc
+ if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
+ // check against full normalized phrase
+ const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
+ REGEX.SEPARATORS,
+ ' ',
+ )
+ const mutedWordNormalizedSeparators = mutedWord.replace(
+ REGEX.SEPARATORS,
+ ' ',
+ )
+ // hyphenated (or other sep) to spaced words
+ if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
+ return true
+
+ /* Disabled for now e.g. `super-cool` to `supercool`
+ const wordNormalizedCompressed = wordNormalizedSeparators.replace(
+ REGEX.WORD_BOUNDARY,
+ '',
+ )
+ const mutedWordNormalizedCompressed =
+ mutedWordNormalizedSeparators.replace(/\s+?/g, '')
+ // hyphenated (or other sep) to non-hyphenated contiguous word
+ if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
+ return true
+ */
+
+ // then individual parts of separated phrases/words
+ const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
+ for (const wp of wordParts) {
+ // still retain internal punctuation
+ if (wp === mutedWord) return true
+ }
+ }
+ }
+ }
+
+ return false
+}
diff --git a/packages/api/src/moderation/subjects/feed-generator.ts b/packages/api/src/moderation/subjects/feed-generator.ts
index d87e62e9044..3afada34b2d 100644
--- a/packages/api/src/moderation/subjects/feed-generator.ts
+++ b/packages/api/src/moderation/subjects/feed-generator.ts
@@ -1,10 +1,24 @@
import { ModerationDecision } from '../decision'
import { ModerationSubjectFeedGenerator, ModerationOpts } from '../types'
+import { decideAccount } from './account'
+import { decideProfile } from './profile'
export function decideFeedGenerator(
- _subject: ModerationSubjectFeedGenerator,
- _opts: ModerationOpts,
+ subject: ModerationSubjectFeedGenerator,
+ opts: ModerationOpts,
): ModerationDecision {
- // TODO handle labels applied on the feed generator itself
- return new ModerationDecision()
+ const acc = new ModerationDecision()
+
+ acc.setDid(subject.creator.did)
+ acc.setIsMe(subject.creator.did === opts.userDid)
+ if (subject.labels?.length) {
+ for (const label of subject.labels) {
+ acc.addLabel('content', label, opts)
+ }
+ }
+ return ModerationDecision.merge(
+ acc,
+ decideAccount(subject.creator, opts),
+ decideProfile(subject.creator, opts),
+ )
}
diff --git a/packages/api/src/moderation/subjects/notification.ts b/packages/api/src/moderation/subjects/notification.ts
index 305dd209890..610766866a8 100644
--- a/packages/api/src/moderation/subjects/notification.ts
+++ b/packages/api/src/moderation/subjects/notification.ts
@@ -1,5 +1,7 @@
import { ModerationDecision } from '../decision'
import { ModerationSubjectNotification, ModerationOpts } from '../types'
+import { decideAccount } from './account'
+import { decideProfile } from './profile'
export function decideNotification(
subject: ModerationSubjectNotification,
@@ -15,5 +17,9 @@ export function decideNotification(
}
}
- return acc
+ return ModerationDecision.merge(
+ acc,
+ decideAccount(subject.author, opts),
+ decideProfile(subject.author, opts),
+ )
}
diff --git a/packages/api/src/moderation/subjects/post.ts b/packages/api/src/moderation/subjects/post.ts
index f93df9b92d9..9fef4a598e8 100644
--- a/packages/api/src/moderation/subjects/post.ts
+++ b/packages/api/src/moderation/subjects/post.ts
@@ -1,5 +1,16 @@
import { ModerationDecision } from '../decision'
+import {
+ AppBskyFeedPost,
+ AppBskyEmbedImages,
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedExternal,
+ AppBskyActorDefs,
+} from '../../client'
import { ModerationSubjectPost, ModerationOpts } from '../types'
+import { hasMutedWord } from '../mutewords'
+import { decideAccount } from './account'
+import { decideProfile } from './profile'
export function decidePost(
subject: ModerationSubjectPost,
@@ -14,6 +25,254 @@ export function decidePost(
acc.addLabel('content', label, opts)
}
}
+ acc.addHidden(checkHiddenPost(subject, opts.prefs.hiddenPosts))
+ if (!acc.isMe) {
+ acc.addMutedWord(checkMutedWords(subject, opts.prefs.mutedWords))
+ }
+
+ let embedAcc
+ if (subject.embed) {
+ if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
+ // quote post
+ embedAcc = decideQuotedPost(subject.embed.record, opts)
+ } else if (
+ AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
+ AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
+ ) {
+ // quoted post with media
+ embedAcc = decideQuotedPost(subject.embed.record.record, opts)
+ }
+ }
+
+ return ModerationDecision.merge(
+ acc,
+ embedAcc?.downgrade(),
+ decideAccount(subject.author, opts),
+ decideProfile(subject.author, opts),
+ )
+}
+
+function decideQuotedPost(
+ subject: AppBskyEmbedRecord.ViewRecord,
+ opts: ModerationOpts,
+) {
+ const acc = new ModerationDecision()
+ acc.setDid(subject.author.did)
+ acc.setIsMe(subject.author.did === opts.userDid)
+ if (subject.labels?.length) {
+ for (const label of subject.labels) {
+ acc.addLabel('content', label, opts)
+ }
+ }
+ return ModerationDecision.merge(
+ acc,
+ decideAccount(subject.author, opts),
+ decideProfile(subject.author, opts),
+ )
+}
- return acc
+function checkHiddenPost(
+ subject: ModerationSubjectPost,
+ hiddenPosts: string[] | undefined,
+) {
+ if (!hiddenPosts?.length) {
+ return false
+ }
+ if (hiddenPosts.includes(subject.uri)) {
+ return true
+ }
+ if (subject.embed) {
+ if (
+ AppBskyEmbedRecord.isViewRecord(subject.embed.record) &&
+ hiddenPosts.includes(subject.embed.record.uri)
+ ) {
+ return true
+ }
+ if (
+ AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
+ AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) &&
+ hiddenPosts.includes(subject.embed.record.record.uri)
+ ) {
+ return true
+ }
+ }
+ return false
+}
+
+function checkMutedWords(
+ subject: ModerationSubjectPost,
+ mutedWords: AppBskyActorDefs.MutedWord[] | undefined,
+) {
+ if (!mutedWords?.length) {
+ return false
+ }
+
+ if (AppBskyFeedPost.isRecord(subject.record)) {
+ // post text
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: subject.record.text,
+ facets: subject.record.facets,
+ outlineTags: subject.record.tags,
+ languages: subject.record.langs,
+ })
+ ) {
+ return true
+ }
+
+ if (
+ subject.record.embed &&
+ AppBskyEmbedImages.isMain(subject.record.embed)
+ ) {
+ // post images
+ for (const image of subject.record.embed.images) {
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: image.alt,
+ languages: subject.record.langs,
+ })
+ ) {
+ return true
+ }
+ }
+ }
+ }
+
+ if (subject.embed) {
+ // quote post
+ if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
+ if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
+ const embeddedPost = subject.embed.record.value
+
+ // quoted post text
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: embeddedPost.text,
+ facets: embeddedPost.facets,
+ outlineTags: embeddedPost.tags,
+ languages: embeddedPost.langs,
+ })
+ ) {
+ return true
+ }
+
+ // quoted post's images
+ if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
+ for (const image of embeddedPost.embed.images) {
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: image.alt,
+ languages: embeddedPost.langs,
+ })
+ ) {
+ return true
+ }
+ }
+ }
+
+ // quoted post's link card
+ if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
+ const { external } = embeddedPost.embed
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: external.title + ' ' + external.description,
+ languages: [],
+ })
+ ) {
+ return true
+ }
+ }
+
+ if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
+ // quoted post's link card when it did a quote + media
+ if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
+ const { external } = embeddedPost.embed.media
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: external.title + ' ' + external.description,
+ languages: [],
+ })
+ ) {
+ return true
+ }
+ }
+
+ // quoted post's images when it did a quote + media
+ if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
+ for (const image of embeddedPost.embed.media.images) {
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: image.alt,
+ languages: AppBskyFeedPost.isRecord(embeddedPost.record)
+ ? embeddedPost.langs
+ : [],
+ })
+ ) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+ // link card
+ else if (AppBskyEmbedExternal.isView(subject.embed)) {
+ const { external } = subject.embed
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: external.title + ' ' + external.description,
+ languages: [],
+ })
+ ) {
+ return true
+ }
+ }
+ // quote post with media
+ else if (
+ AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
+ AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
+ ) {
+ // quoted post text
+ if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
+ const post = subject.embed.record.record.value
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: post.text,
+ facets: post.facets,
+ outlineTags: post.tags,
+ languages: post.langs,
+ })
+ ) {
+ return true
+ }
+ }
+
+ // quoted post images
+ if (AppBskyEmbedImages.isView(subject.embed.media)) {
+ for (const image of subject.embed.media.images) {
+ if (
+ hasMutedWord({
+ mutedWords,
+ text: image.alt,
+ languages: AppBskyFeedPost.isRecord(subject.record)
+ ? subject.record.langs
+ : [],
+ })
+ ) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ return false
}
diff --git a/packages/api/src/moderation/subjects/user-list.ts b/packages/api/src/moderation/subjects/user-list.ts
index ad7cd861c49..f5ed15177d9 100644
--- a/packages/api/src/moderation/subjects/user-list.ts
+++ b/packages/api/src/moderation/subjects/user-list.ts
@@ -1,10 +1,44 @@
+import { AtUri } from '@atproto/syntax'
+import { AppBskyActorDefs } from '../../client/index'
import { ModerationDecision } from '../decision'
import { ModerationSubjectUserList, ModerationOpts } from '../types'
+import { decideAccount } from './account'
+import { decideProfile } from './profile'
export function decideUserList(
- _subject: ModerationSubjectUserList,
- _opts: ModerationOpts,
+ subject: ModerationSubjectUserList,
+ opts: ModerationOpts,
): ModerationDecision {
- // TODO handle labels applied on the list itself
- return new ModerationDecision()
+ const acc = new ModerationDecision()
+
+ const creator = isProfile(subject.creator) ? subject.creator : undefined
+
+ if (creator) {
+ acc.setDid(creator.did)
+ acc.setIsMe(creator.did === opts.userDid)
+ if (subject.labels?.length) {
+ for (const label of subject.labels) {
+ acc.addLabel('content', label, opts)
+ }
+ }
+ return ModerationDecision.merge(
+ acc,
+ decideAccount(creator, opts),
+ decideProfile(creator, opts),
+ )
+ }
+
+ const creatorDid = new AtUri(subject.uri).hostname
+ acc.setDid(creatorDid)
+ acc.setIsMe(creatorDid === opts.userDid)
+ if (subject.labels?.length) {
+ for (const label of subject.labels) {
+ acc.addLabel('content', label, opts)
+ }
+ }
+ return acc
+}
+
+function isProfile(v: any): v is AppBskyActorDefs.ProfileViewBasic {
+ return v && typeof v === 'object' && 'did' in v
}
diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts
index e43a8f8e6bf..bbf8d842f23 100644
--- a/packages/api/src/moderation/types.ts
+++ b/packages/api/src/moderation/types.ts
@@ -39,6 +39,10 @@ export const MUTE_BEHAVIOR: ModerationBehavior = {
contentList: 'blur',
contentView: 'inform',
}
+export const MUTEWORD_BEHAVIOR: ModerationBehavior = {
+ contentList: 'blur',
+ contentView: 'blur',
+}
export const HIDE_BEHAVIOR: ModerationBehavior = {
contentList: 'blur',
contentView: 'blur',
@@ -58,9 +62,9 @@ export type LabelValueDefinitionFlag =
| 'unauthed'
| 'no-self'
-export interface InterprettedLabelValueDefinition
+export interface InterpretedLabelValueDefinition
extends ComAtprotoLabelDefs.LabelValueDefinition {
- // identifier: string
+ definedBy?: string | undefined // did of labeler or undefined for global
configurable: boolean
defaultSetting: LabelPreference // type narrowing
flags: LabelValueDefinitionFlag[]
@@ -73,7 +77,7 @@ export interface InterprettedLabelValueDefinition
export type LabelDefinitionMap = Record<
KnownLabelValue,
- InterprettedLabelValueDefinition
+ InterpretedLabelValueDefinition
>
// subjects
@@ -111,23 +115,56 @@ export type ModerationCauseSource =
| { type: 'labeler'; did: string }
export type ModerationCause =
- | { type: 'blocking'; source: ModerationCauseSource; priority: 3 }
- | { type: 'blocked-by'; source: ModerationCauseSource; priority: 4 }
- | { type: 'block-other'; source: ModerationCauseSource; priority: 4 }
+ | {
+ type: 'blocking'
+ source: ModerationCauseSource
+ priority: 3
+ downgraded?: boolean
+ }
+ | {
+ type: 'blocked-by'
+ source: ModerationCauseSource
+ priority: 4
+ downgraded?: boolean
+ }
+ | {
+ type: 'block-other'
+ source: ModerationCauseSource
+ priority: 4
+ downgraded?: boolean
+ }
| {
type: 'label'
source: ModerationCauseSource
label: Label
- labelDef: InterprettedLabelValueDefinition
+ labelDef: InterpretedLabelValueDefinition
+ target: LabelTarget
setting: LabelPreference
behavior: ModerationBehavior
noOverride: boolean
priority: 1 | 2 | 5 | 7 | 8
+ downgraded?: boolean
+ }
+ | {
+ type: 'muted'
+ source: ModerationCauseSource
+ priority: 6
+ downgraded?: boolean
+ }
+ | {
+ type: 'mute-word'
+ source: ModerationCauseSource
+ priority: 6
+ downgraded?: boolean
+ }
+ | {
+ type: 'hidden'
+ source: ModerationCauseSource
+ priority: 6
+ downgraded?: boolean
}
- | { type: 'muted'; source: ModerationCauseSource; priority: 6 }
- | { type: 'hidden'; source: ModerationCauseSource; priority: 6 }
-export interface ModerationPrefsModerator {
+export interface ModerationPrefsLabeler {
did: string
labels: Record
}
@@ -135,7 +172,9 @@ export interface ModerationPrefsModerator {
export interface ModerationPrefs {
adultContentEnabled: boolean
labels: Record
- mods: ModerationPrefsModerator[]
+ labelers: ModerationPrefsLabeler[]
+ mutedWords: AppBskyActorDefs.MutedWord[]
+ hiddenPosts: string[]
}
export interface ModerationOpts {
@@ -144,5 +183,5 @@ export interface ModerationOpts {
/**
* Map of labeler did -> custom definitions
*/
- labelDefs?: Record
+ labelDefs?: Record
}
diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts
index e2a8f2251e9..aaf800aa8aa 100644
--- a/packages/api/src/moderation/util.ts
+++ b/packages/api/src/moderation/util.ts
@@ -4,7 +4,12 @@ import {
AppBskyLabelerDefs,
ComAtprotoLabelDefs,
} from '../client'
-import { InterprettedLabelValueDefinition, ModerationBehavior } from './types'
+import {
+ InterpretedLabelValueDefinition,
+ ModerationBehavior,
+ LabelPreference,
+ LabelValueDefinitionFlag,
+} from './types'
export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View {
return Boolean(embed && AppBskyEmbedRecord.isView(embed))
@@ -18,7 +23,8 @@ export function isQuotedPostWithMedia(
export function interpretLabelValueDefinition(
def: ComAtprotoLabelDefs.LabelValueDefinition,
-): InterprettedLabelValueDefinition {
+ definedBy: string | undefined,
+): InterpretedLabelValueDefinition {
const behaviors: {
account: ModerationBehavior
profile: ModerationBehavior
@@ -39,23 +45,21 @@ export function interpretLabelValueDefinition(
behaviors.account.profileList = alertOrInform
behaviors.account.profileView = alertOrInform
behaviors.account.contentList = 'blur'
- behaviors.account.contentView = alertOrInform
+ behaviors.account.contentView = def.adultOnly ? 'blur' : alertOrInform
// target=profile, blurs=content
- behaviors.account.profileView = alertOrInform
- behaviors.profile.avatar = 'blur'
- behaviors.profile.banner = 'blur'
- behaviors.profile.displayName = 'blur'
+ behaviors.profile.profileList = alertOrInform
+ behaviors.profile.profileView = alertOrInform
// target=content, blurs=content
behaviors.content.contentList = 'blur'
- behaviors.content.contentView = alertOrInform
+ behaviors.content.contentView = def.adultOnly ? 'blur' : alertOrInform
} else if (def.blurs === 'media') {
// target=account, blurs=media
behaviors.account.profileList = alertOrInform
behaviors.account.profileView = alertOrInform
behaviors.account.avatar = 'blur'
behaviors.account.banner = 'blur'
- behaviors.account.contentMedia = 'blur'
// target=profile, blurs=media
+ behaviors.profile.profileList = alertOrInform
behaviors.profile.profileView = alertOrInform
behaviors.profile.avatar = 'blur'
behaviors.profile.banner = 'blur'
@@ -68,29 +72,42 @@ export function interpretLabelValueDefinition(
behaviors.account.contentList = alertOrInform
behaviors.account.contentView = alertOrInform
// target=profile, blurs=none
+ behaviors.profile.profileList = alertOrInform
behaviors.profile.profileView = alertOrInform
// target=content, blurs=none
behaviors.content.contentList = alertOrInform
behaviors.content.contentView = alertOrInform
}
+ let defaultSetting: LabelPreference = 'warn'
+ if (def.defaultSetting === 'hide' || def.defaultSetting === 'ignore') {
+ defaultSetting = def.defaultSetting as LabelPreference
+ }
+
+ const flags: LabelValueDefinitionFlag[] = ['no-self']
+ if (def.adultOnly) {
+ flags.push('adult')
+ }
+
return {
...def,
+ definedBy,
configurable: true,
- defaultSetting: 'warn',
- flags: ['no-self'],
+ defaultSetting,
+ flags,
behaviors,
}
}
export function interpretLabelValueDefinitions(
- modserviceView: AppBskyLabelerDefs.LabelerViewDetailed,
-): InterprettedLabelValueDefinition[] {
- return (modserviceView.policies?.labelValueDefinitions || [])
+ labelerView: AppBskyLabelerDefs.LabelerViewDetailed,
+): InterpretedLabelValueDefinition[] {
+ return (labelerView.policies?.labelValueDefinitions || [])
.filter(
(labelValDef) =>
- ComAtprotoLabelDefs.isLabelValueDefinition(labelValDef) &&
ComAtprotoLabelDefs.validateLabelValueDefinition(labelValDef).success,
)
- .map((labelValDef) => interpretLabelValueDefinition(labelValDef))
+ .map((labelValDef) =>
+ interpretLabelValueDefinition(labelValDef, labelerView.creator.did),
+ )
}
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
index dac0666a41a..a633ff79a33 100644
--- a/packages/api/src/types.ts
+++ b/packages/api/src/types.ts
@@ -1,6 +1,11 @@
import { AppBskyActorDefs } from './client'
import { ModerationPrefs } from './moderation/types'
+/**
+ * Supported proxy targets
+ */
+export type AtprotoServiceType = 'atproto_labeler'
+
/**
* Used by the PersistSessionHandler to indicate what change occurred
*/
@@ -67,7 +72,8 @@ export type AtpAgentFetchHandler = (
* AtpAgent global config opts
*/
export interface AtpAgentGlobalOpts {
- fetch: AtpAgentFetchHandler
+ fetch?: AtpAgentFetchHandler
+ appLabelers?: string[]
}
/**
@@ -113,6 +119,4 @@ export interface BskyPreferences {
moderationPrefs: ModerationPrefs
birthDate: Date | undefined
interests: BskyInterestsPreference
- mutedWords: AppBskyActorDefs.MutedWord[]
- hiddenPosts: string[]
}
diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts
index 89491cc1616..f618c0a5bc9 100644
--- a/packages/api/tests/agent.test.ts
+++ b/packages/api/tests/agent.test.ts
@@ -1,10 +1,12 @@
import assert from 'assert'
+import getPort from 'get-port'
import { defaultFetchHandler } from '@atproto/xrpc'
import {
AtpAgent,
AtpAgentFetchHandlerResponse,
AtpSessionEvent,
AtpSessionData,
+ BSKY_LABELER_DID,
} from '..'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { getPdsEndpoint, isValidDidDoc } from '@atproto/common-web'
@@ -26,6 +28,14 @@ describe('agent', () => {
await network.close()
})
+ it('clones correctly', () => {
+ const persistSession = (_evt: AtpSessionEvent, _sess?: AtpSessionData) => {}
+ const agent = new AtpAgent({ service: network.pds.url, persistSession })
+ const agent2 = agent.clone()
+ expect(agent2 instanceof AtpAgent).toBeTruthy()
+ expect(agent.service).toEqual(agent2.service)
+ })
+
it('creates a new session on account creation.', async () => {
const events: string[] = []
const sessions: (AtpSessionData | undefined)[] = []
@@ -481,19 +491,75 @@ describe('agent', () => {
})
})
+ describe('App labelers header', () => {
+ it('adds the labelers header as expected', async () => {
+ const port = await getPort()
+ const server = await createHeaderEchoServer(port)
+ const agent = new AtpAgent({ service: `http://localhost:${port}` })
+ const agent2 = new AtpAgent({ service: `http://localhost:${port}` })
+
+ const res1 = await agent.com.atproto.server.describeServer()
+ expect(res1.data['atproto-accept-labelers']).toEqual(
+ `${BSKY_LABELER_DID};redact`,
+ )
+
+ AtpAgent.configure({ appLabelers: ['did:plc:test1', 'did:plc:test2'] })
+ const res2 = await agent.com.atproto.server.describeServer()
+ expect(res2.data['atproto-accept-labelers']).toEqual(
+ 'did:plc:test1;redact, did:plc:test2;redact',
+ )
+ const res3 = await agent2.com.atproto.server.describeServer()
+ expect(res3.data['atproto-accept-labelers']).toEqual(
+ 'did:plc:test1;redact, did:plc:test2;redact',
+ )
+ AtpAgent.configure({ appLabelers: [BSKY_LABELER_DID] })
+
+ await new Promise((r) => server.close(r))
+ })
+ })
+
describe('configureLabelersHeader', () => {
it('adds the labelers header as expected', async () => {
- const server = await createHeaderEchoServer(15991)
- const agent = new AtpAgent({ service: 'http://localhost:15991' })
+ const port = await getPort()
+ const server = await createHeaderEchoServer(port)
+ const agent = new AtpAgent({ service: `http://localhost:${port}` })
agent.configureLabelersHeader(['did:plc:test1'])
const res1 = await agent.com.atproto.server.describeServer()
- expect(res1.data['atproto-labelers']).toEqual('did:plc:test1')
+ expect(res1.data['atproto-accept-labelers']).toEqual(
+ `${BSKY_LABELER_DID};redact, did:plc:test1`,
+ )
agent.configureLabelersHeader(['did:plc:test1', 'did:plc:test2'])
const res2 = await agent.com.atproto.server.describeServer()
- expect(res2.data['atproto-labelers']).toEqual(
- 'did:plc:test1,did:plc:test2',
+ expect(res2.data['atproto-accept-labelers']).toEqual(
+ `${BSKY_LABELER_DID};redact, did:plc:test1, did:plc:test2`,
+ )
+
+ await new Promise((r) => server.close(r))
+ })
+ })
+
+ describe('configureProxyHeader', () => {
+ it('adds the proxy header as expected', async () => {
+ const port = await getPort()
+ const server = await createHeaderEchoServer(port)
+ const agent = new AtpAgent({ service: `http://localhost:${port}` })
+
+ const res1 = await agent.com.atproto.server.describeServer()
+ expect(res1.data['atproto-proxy']).toBeFalsy()
+
+ agent.configureProxyHeader('atproto_labeler', 'did:plc:test1')
+ const res2 = await agent.com.atproto.server.describeServer()
+ expect(res2.data['atproto-proxy']).toEqual(
+ 'did:plc:test1#atproto_labeler',
+ )
+
+ const res3 = await agent
+ .withProxy('atproto_labeler', 'did:plc:test2')
+ .com.atproto.server.describeServer()
+ expect(res3.data['atproto-proxy']).toEqual(
+ 'did:plc:test2#atproto_labeler',
)
await new Promise((r) => server.close(r))
diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts
index 197fc80e560..bae98dfe65d 100644
--- a/packages/api/tests/bsky-agent.test.ts
+++ b/packages/api/tests/bsky-agent.test.ts
@@ -3,7 +3,6 @@ import {
BskyAgent,
ComAtprotoRepoPutRecord,
AppBskyActorProfile,
- BSKY_MODSERVICE_DID,
DEFAULT_LABEL_SETTINGS,
} from '..'
@@ -34,6 +33,13 @@ describe('agent', () => {
}
}
+ it('clones correctly', () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+ const agent2 = agent.clone()
+ expect(agent2 instanceof BskyAgent).toBeTruthy()
+ expect(agent.service).toEqual(agent2.service)
+ })
+
it('upsertProfile correctly creates and updates profiles.', async () => {
const agent = new BskyAgent({ service: network.pds.url })
@@ -229,12 +235,9 @@ describe('agent', () => {
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -253,8 +256,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setAdultContentEnabled(true)
@@ -263,12 +264,9 @@ describe('agent', () => {
moderationPrefs: {
adultContentEnabled: true,
labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -287,8 +285,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setAdultContentEnabled(false)
@@ -297,12 +293,9 @@ describe('agent', () => {
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -321,8 +314,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setContentLabelPref('misinfo', 'hide')
@@ -331,12 +322,9 @@ describe('agent', () => {
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' },
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -355,8 +343,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setContentLabelPref('spam', 'ignore')
@@ -369,12 +355,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -393,8 +376,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.addSavedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -410,12 +391,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -434,8 +412,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -451,12 +427,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -475,8 +448,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.removePinnedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -492,12 +463,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -516,8 +484,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -533,12 +499,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -557,8 +520,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -574,12 +535,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -598,8 +556,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake2')
@@ -621,12 +577,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -645,8 +598,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -662,12 +613,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -686,8 +634,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' })
@@ -703,12 +649,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -727,8 +670,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setFeedViewPrefs('home', { hideReplies: true })
@@ -744,12 +685,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -768,8 +706,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setFeedViewPrefs('home', { hideReplies: false })
@@ -785,12 +721,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -809,8 +742,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setFeedViewPrefs('other', { hideReplies: true })
@@ -826,12 +757,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -857,8 +785,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setThreadViewPrefs({ sort: 'random' })
@@ -874,12 +800,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -905,8 +828,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setThreadViewPrefs({ sort: 'oldest' })
@@ -922,12 +843,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -953,8 +871,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setInterestsPref({ tags: ['foo', 'bar'] })
@@ -970,12 +886,9 @@ describe('agent', () => {
misinfo: 'hide',
spam: 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1001,8 +914,6 @@ describe('agent', () => {
interests: {
tags: ['foo', 'bar'],
},
- mutedWords: [],
- hiddenPosts: [],
})
})
@@ -1038,18 +949,18 @@ describe('agent', () => {
visibility: 'warn',
},
{
- $type: 'app.bsky.actor.defs#modsPref',
- mods: [
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
},
],
},
{
- $type: 'app.bsky.actor.defs#modsPref',
- mods: [
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
},
{
did: 'did:plc:other',
@@ -1133,9 +1044,9 @@ describe('agent', () => {
...DEFAULT_LABEL_SETTINGS,
porn: 'warn',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
{
@@ -1143,6 +1054,8 @@ describe('agent', () => {
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1161,8 +1074,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setAdultContentEnabled(false)
@@ -1177,9 +1088,9 @@ describe('agent', () => {
...DEFAULT_LABEL_SETTINGS,
porn: 'warn',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
{
@@ -1187,6 +1098,8 @@ describe('agent', () => {
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1205,8 +1118,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setContentLabelPref('porn', 'ignore')
@@ -1219,11 +1130,12 @@ describe('agent', () => {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
+ nsfw: 'ignore',
porn: 'ignore',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
{
@@ -1231,6 +1143,8 @@ describe('agent', () => {
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1249,11 +1163,9 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
- await agent.removeModService('did:plc:other')
+ await agent.removeLabeler('did:plc:other')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: {
pinned: [],
@@ -1263,14 +1175,17 @@ describe('agent', () => {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
+ nsfw: 'ignore',
porn: 'ignore',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1289,8 +1204,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake')
@@ -1303,14 +1216,17 @@ describe('agent', () => {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
+ nsfw: 'ignore',
porn: 'ignore',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1329,8 +1245,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' })
@@ -1343,14 +1257,17 @@ describe('agent', () => {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
+ nsfw: 'ignore',
porn: 'ignore',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1369,8 +1286,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
await agent.setFeedViewPrefs('home', {
@@ -1394,14 +1309,17 @@ describe('agent', () => {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
+ nsfw: 'ignore',
porn: 'ignore',
},
- mods: [
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@@ -1420,8 +1338,6 @@ describe('agent', () => {
interests: {
tags: [],
},
- mutedWords: [],
- hiddenPosts: [],
})
const res = await agent.app.bsky.actor.getPreferences()
@@ -1437,10 +1353,15 @@ describe('agent', () => {
visibility: 'ignore',
},
{
- $type: 'app.bsky.actor.defs#modsPref',
- mods: [
+ $type: 'app.bsky.actor.defs#contentLabelPref',
+ label: 'nsfw',
+ visibility: 'ignore',
+ },
+ {
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [
{
- did: BSKY_MODSERVICE_DID,
+ did: 'did:plc:first-labeler',
},
],
},
@@ -1496,7 +1417,7 @@ describe('agent', () => {
await agent.upsertMutedWords(mutedWords)
await agent.upsertMutedWords(mutedWords) // double
await expect(agent.getPreferences()).resolves.toHaveProperty(
- 'mutedWords',
+ 'moderationPrefs.mutedWords',
mutedWords,
)
})
@@ -1508,7 +1429,7 @@ describe('agent', () => {
// is sanitized to `hashtag`
await agent.upsertMutedWords([{ value: '#hashtag', targets: ['tag'] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#hashtag')).toBeFalsy()
// merged with existing
@@ -1531,7 +1452,7 @@ describe('agent', () => {
})
await agent.updateMutedWord({ value: 'tag_then_none', targets: [] })
await agent.updateMutedWord({ value: 'no_exist', targets: ['tag'] })
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(
mutedWords.find((m) => m.value === 'tag_then_content'),
@@ -1556,7 +1477,7 @@ describe('agent', () => {
value: '#just_a_tag',
targets: ['tag', 'content'],
})
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({
value: 'just_a_tag',
targets: ['tag'],
@@ -1567,7 +1488,7 @@ describe('agent', () => {
await agent.removeMutedWord({ value: 'tag_then_content', targets: [] })
await agent.removeMutedWord({ value: 'tag_then_both', targets: [] })
await agent.removeMutedWord({ value: 'tag_then_none', targets: [] })
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(
mutedWords.find((m) => m.value === 'tag_then_content'),
@@ -1578,17 +1499,17 @@ describe('agent', () => {
it('removeMutedWord with #, no match, no removal', async () => {
await agent.removeMutedWord({ value: '#hashtag', targets: [] })
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
// was inserted with #hashtag, but we don't sanitize on remove
expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy()
})
it('single-hash #', async () => {
- const prev = await agent.getPreferences()
+ const prev = (await agent.getPreferences()).moderationPrefs
const length = prev.mutedWords.length
await agent.upsertMutedWords([{ value: '#', targets: [] }])
- const end = await agent.getPreferences()
+ const end = (await agent.getPreferences()).moderationPrefs
// sanitized to empty string, not inserted
expect(end.mutedWords.length).toEqual(length)
@@ -1596,65 +1517,65 @@ describe('agent', () => {
it('multi-hash ##', async () => {
await agent.upsertMutedWords([{ value: '##', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#')).toBeTruthy()
})
it('multi-hash ##hashtag', async () => {
await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }])
- const a = await agent.getPreferences()
+ const a = (await agent.getPreferences()).moderationPrefs
expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy()
await agent.removeMutedWord({ value: '#hashtag', targets: [] })
- const b = await agent.getPreferences()
+ const b = (await agent.getPreferences()).moderationPrefs
expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy()
})
it('hash emoji #️⃣', async () => {
await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '#️⃣', targets: [] })
- const end = await agent.getPreferences()
+ const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
})
it('hash emoji ##️⃣', async () => {
await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '#️⃣', targets: [] })
- const end = await agent.getPreferences()
+ const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
})
it('hash emoji ###️⃣', async () => {
await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '##️⃣', targets: [] })
- const end = await agent.getPreferences()
+ const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy()
})
describe(`invalid characters`, () => {
it('zero width space', async () => {
- const prev = await agent.getPreferences()
+ const prev = (await agent.getPreferences()).moderationPrefs
const length = prev.mutedWords.length
await agent.upsertMutedWords([{ value: '#', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.length).toEqual(length)
})
@@ -1663,7 +1584,7 @@ describe('agent', () => {
await agent.upsertMutedWords([
{ value: 'test value\n with newline', targets: [] },
])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(
mutedWords.find((m) => m.value === 'test value with newline'),
@@ -1674,7 +1595,7 @@ describe('agent', () => {
await agent.upsertMutedWords([
{ value: 'test value\n\r with newline', targets: [] },
])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(
mutedWords.find((m) => m.value === 'test value with newline'),
@@ -1683,14 +1604,14 @@ describe('agent', () => {
it('empty space', async () => {
await agent.upsertMutedWords([{ value: ' ', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy()
})
it('leading/trailing space', async () => {
await agent.upsertMutedWords([{ value: ' trim ', targets: [] }])
- const { mutedWords } = await agent.getPreferences()
+ const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy()
})
@@ -1714,7 +1635,7 @@ describe('agent', () => {
await agent.hidePost(postUri)
await agent.hidePost(postUri) // double, should dedupe
await expect(agent.getPreferences()).resolves.toHaveProperty(
- 'hiddenPosts',
+ 'moderationPrefs.hiddenPosts',
[postUri],
)
})
@@ -1722,13 +1643,13 @@ describe('agent', () => {
it('unhidePost', async () => {
await agent.unhidePost(postUri)
await expect(agent.getPreferences()).resolves.toHaveProperty(
- 'hiddenPosts',
+ 'moderationPrefs.hiddenPosts',
[],
)
// no issues calling a second time
await agent.unhidePost(postUri)
await expect(agent.getPreferences()).resolves.toHaveProperty(
- 'hiddenPosts',
+ 'moderationPrefs.hiddenPosts',
[],
)
})
diff --git a/packages/api/tests/moderation-behaviors.test.ts b/packages/api/tests/moderation-behaviors.test.ts
index 4f782dd0155..686956fd6ae 100644
--- a/packages/api/tests/moderation-behaviors.test.ts
+++ b/packages/api/tests/moderation-behaviors.test.ts
@@ -129,11 +129,9 @@ const SCENARIOS: SuiteScenarios = {
author: 'alice',
labels: { profile: ['!hide'] },
behaviors: {
- profileList: ['filter'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
- contentList: ['filter'],
},
},
"Imperative label ('!hide') on post": {
@@ -155,7 +153,6 @@ const SCENARIOS: SuiteScenarios = {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
- contentList: ['filter'],
},
},
"Imperative label ('!hide') on author account": {
@@ -172,55 +169,6 @@ const SCENARIOS: SuiteScenarios = {
},
},
- "Imperative label ('!no-promote') on account": {
- cfg: 'none',
- subject: 'profile',
- author: 'alice',
- labels: { account: ['!no-promote'] },
- behaviors: {
- profileList: ['filter'],
- contentList: ['filter'],
- },
- },
- "Imperative label ('!no-promote') on profile": {
- cfg: 'none',
- subject: 'profile',
- author: 'alice',
- labels: { profile: ['!no-promote'] },
- behaviors: {
- profileList: ['filter'],
- contentList: ['filter'],
- },
- },
- "Imperative label ('!no-promote') on post": {
- cfg: 'none',
- subject: 'post',
- author: 'alice',
- labels: { post: ['!no-promote'] },
- behaviors: {
- contentList: ['filter'],
- },
- },
- "Imperative label ('!no-promote') on author profile": {
- cfg: 'none',
- subject: 'post',
- author: 'alice',
- labels: { profile: ['!no-promote'] },
- behaviors: {
- profileList: ['filter'],
- contentList: ['filter'],
- },
- },
- "Imperative label ('!no-promote') on author account": {
- cfg: 'none',
- subject: 'post',
- author: 'alice',
- labels: { account: ['!no-promote'] },
- behaviors: {
- contentList: ['filter'],
- },
- },
-
"Imperative label ('!warn') on account": {
cfg: 'none',
subject: 'profile',
@@ -403,10 +351,8 @@ const SCENARIOS: SuiteScenarios = {
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
- profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
- contentList: ['filter'],
},
},
"Blur-media label ('porn') on post (hide)": {
@@ -427,7 +373,6 @@ const SCENARIOS: SuiteScenarios = {
behaviors: {
avatar: ['blur'],
banner: ['blur'],
- contentList: ['filter'],
},
},
"Blur-media label ('porn') on author account (hide)": {
@@ -547,10 +492,10 @@ const SCENARIOS: SuiteScenarios = {
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
- profileList: ['filter'],
+ profileList: [],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
- contentList: ['filter'],
+ contentList: [],
},
},
'Adult-only label on post when adult content is disabled': {
@@ -571,7 +516,7 @@ const SCENARIOS: SuiteScenarios = {
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
- contentList: ['filter'],
+ contentList: [],
},
},
'Adult-only label on author account when adult content is disabled': {
@@ -842,12 +787,12 @@ const SCENARIOS: SuiteScenarios = {
author: 'alice',
labels: { account: ['!warn'], profile: ['!hide'] },
behaviors: {
- profileList: ['filter', 'blur'],
+ profileList: ['blur'],
profileView: ['blur'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
- contentList: ['filter', 'blur'],
+ contentList: ['blur'],
contentView: ['blur'],
},
},
diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts
index 2d177c3e3de..3e051fb0498 100644
--- a/packages/api/tests/moderation-custom-labels.test.ts
+++ b/packages/api/tests/moderation-custom-labels.test.ts
@@ -3,7 +3,7 @@ import {
moderatePost,
mock,
ModerationOpts,
- InterprettedLabelValueDefinition,
+ InterpretedLabelValueDefinition,
interpretLabelValueDefinition,
} from '../src'
import './util/moderation-behavior'
@@ -38,14 +38,10 @@ const TESTS: Scenario[] = [
contentView: ['alert'],
},
profile: {
- profileList: ['filter'],
- avatar: ['blur'],
- banner: ['blur'],
- displayName: ['blur'],
- contentList: ['filter'],
+ profileList: ['alert'],
+ profileView: ['alert'],
},
post: {
- profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: ['alert'],
},
@@ -60,14 +56,10 @@ const TESTS: Scenario[] = [
contentView: ['inform'],
},
profile: {
- profileList: ['filter'],
- avatar: ['blur'],
- banner: ['blur'],
- displayName: ['blur'],
- contentList: ['filter'],
+ profileList: ['inform'],
+ profileView: ['inform'],
},
post: {
- profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
@@ -82,14 +74,10 @@ const TESTS: Scenario[] = [
contentView: [],
},
profile: {
- profileList: ['filter'],
- avatar: ['blur'],
- banner: ['blur'],
- displayName: ['blur'],
- contentList: ['filter'],
+ profileList: [],
+ profileView: [],
},
post: {
- profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: [],
},
@@ -104,17 +92,14 @@ const TESTS: Scenario[] = [
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
- contentMedia: ['blur'],
},
profile: {
- profileList: ['filter'],
+ profileList: ['alert'],
profileView: ['alert'],
avatar: ['blur'],
banner: ['blur'],
- contentList: ['filter'],
},
post: {
- profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
@@ -128,17 +113,14 @@ const TESTS: Scenario[] = [
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
- contentMedia: ['blur'],
},
profile: {
- profileList: ['filter'],
+ profileList: ['inform'],
profileView: ['inform'],
avatar: ['blur'],
banner: ['blur'],
- contentList: ['filter'],
},
post: {
- profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
@@ -151,16 +133,12 @@ const TESTS: Scenario[] = [
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
- contentMedia: ['blur'],
},
profile: {
- profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
- contentList: ['filter'],
},
post: {
- profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
@@ -176,12 +154,10 @@ const TESTS: Scenario[] = [
contentView: ['alert'],
},
profile: {
- profileList: ['filter'],
+ profileList: ['alert'],
profileView: ['alert'],
- contentList: ['filter'],
},
post: {
- profileList: ['filter'],
contentList: ['filter', 'alert'],
contentView: ['alert'],
},
@@ -196,12 +172,10 @@ const TESTS: Scenario[] = [
contentView: ['inform'],
},
profile: {
- profileList: ['filter'],
+ profileList: ['inform'],
profileView: ['inform'],
- contentList: ['filter'],
},
post: {
- profileList: ['filter'],
contentList: ['filter', 'inform'],
contentView: ['inform'],
},
@@ -213,12 +187,8 @@ const TESTS: Scenario[] = [
profileList: ['filter'],
contentList: ['filter'],
},
- profile: {
- profileList: ['filter'],
- contentList: ['filter'],
- },
+ profile: {},
post: {
- profileList: ['filter'],
contentList: ['filter'],
},
},
@@ -300,27 +270,27 @@ describe('Moderation: custom labels', () => {
}),
modOpts(blurs, severity),
)
- expect(res.ui('profileList')).toBeModerationResult(
- expected.profileList || [],
- )
- expect(res.ui('profileView')).toBeModerationResult(
- expected.profileView || [],
- )
- expect(res.ui('avatar')).toBeModerationResult(expected.avatar || [])
- expect(res.ui('banner')).toBeModerationResult(expected.banner || [])
- expect(res.ui('displayName')).toBeModerationResult(
- expected.displayName || [],
- )
- expect(res.ui('contentList')).toBeModerationResult(
- expected.contentList || [],
- )
- expect(res.ui('contentView')).toBeModerationResult(
- expected.contentView || [],
- )
- expect(res.ui('contentMedia')).toBeModerationResult(
- expected.contentMedia || [],
- )
}
+ expect(res.ui('profileList')).toBeModerationResult(
+ expected.profileList || [],
+ )
+ expect(res.ui('profileView')).toBeModerationResult(
+ expected.profileView || [],
+ )
+ expect(res.ui('avatar')).toBeModerationResult(expected.avatar || [])
+ expect(res.ui('banner')).toBeModerationResult(expected.banner || [])
+ expect(res.ui('displayName')).toBeModerationResult(
+ expected.displayName || [],
+ )
+ expect(res.ui('contentList')).toBeModerationResult(
+ expected.contentList || [],
+ )
+ expect(res.ui('contentView')).toBeModerationResult(
+ expected.contentView || [],
+ )
+ expect(res.ui('contentMedia')).toBeModerationResult(
+ expected.contentMedia || [],
+ )
},
)
})
@@ -331,12 +301,14 @@ function modOpts(blurs: string, severity: string): ModerationOpts {
prefs: {
adultContentEnabled: true,
labels: {},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: { custom: 'hide' },
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
labelDefs: {
'did:web:labeler.test': [makeCustomLabel(blurs, severity)],
@@ -347,12 +319,15 @@ function modOpts(blurs: string, severity: string): ModerationOpts {
function makeCustomLabel(
blurs: string,
severity: string,
-): InterprettedLabelValueDefinition {
- return interpretLabelValueDefinition({
- identifier: 'custom',
- blurs,
- severity,
- defaultSetting: 'warn',
- locales: [],
- })
+): InterpretedLabelValueDefinition {
+ return interpretLabelValueDefinition(
+ {
+ identifier: 'custom',
+ blurs,
+ severity,
+ defaultSetting: 'warn',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ )
}
diff --git a/packages/api/tests/moderation-mutewords.test.ts b/packages/api/tests/moderation-mutewords.test.ts
new file mode 100644
index 00000000000..18a2f556887
--- /dev/null
+++ b/packages/api/tests/moderation-mutewords.test.ts
@@ -0,0 +1,691 @@
+import { RichText, mock, moderatePost } from '../src/'
+
+import { hasMutedWord } from '../src/moderation/mutewords'
+
+describe(`hasMutedWord`, () => {
+ describe(`tags`, () => {
+ it(`match: outline tag`, () => {
+ const rt = new RichText({
+ text: `This is a post #inlineTag`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'outlineTag', targets: ['tag'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: ['outlineTag'],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: inline tag`, () => {
+ const rt = new RichText({
+ text: `This is a post #inlineTag`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'inlineTag', targets: ['tag'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: ['outlineTag'],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: content target matches inline tag`, () => {
+ const rt = new RichText({
+ text: `This is a post #inlineTag`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'inlineTag', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: ['outlineTag'],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`no match: only tag targets`, () => {
+ const rt = new RichText({
+ text: `This is a post`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'inlineTag', targets: ['tag'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+ })
+
+ describe(`early exits`, () => {
+ it(`match: single character 希`, () => {
+ /**
+ * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
+ */
+ const rt = new RichText({
+ text: `改善希望です`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: '希', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`no match: long muted word, short post`, () => {
+ const rt = new RichText({
+ text: `hey`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'politics', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+
+ it(`match: exact text`, () => {
+ const rt = new RichText({
+ text: `javascript`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'javascript', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`general content`, () => {
+ it(`match: word within post`, () => {
+ const rt = new RichText({
+ text: `This is a post about javascript`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'javascript', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`no match: partial word`, () => {
+ const rt = new RichText({
+ text: `Use your brain, Eric`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'ai', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+
+ it(`match: multiline`, () => {
+ const rt = new RichText({
+ text: `Use your\n\tbrain, Eric`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'brain', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: :)`, () => {
+ const rt = new RichText({
+ text: `So happy :)`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: `:)`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`punctuation semi-fuzzy`, () => {
+ describe(`yay!`, () => {
+ const rt = new RichText({
+ text: `We're federating, yay!`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: yay!`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'yay!', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: yay`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'yay', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`y!ppee!!`, () => {
+ const rt = new RichText({
+ text: `We're federating, y!ppee!!`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: y!ppee`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'y!ppee', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ // single exclamation point, source has double
+ it(`no match: y!ppee!`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'y!ppee!', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`Why so S@assy?`, () => {
+ const rt = new RichText({
+ text: `Why so S@assy?`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: S@assy`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'S@assy', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: s@assy`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 's@assy', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`New York Times`, () => {
+ const rt = new RichText({
+ text: `New York Times`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ // case insensitive
+ it(`match: new york times`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'new york times', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`!command`, () => {
+ const rt = new RichText({
+ text: `Idk maybe a bot !command`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: !command`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `!command`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: command`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `command`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`no match: !command`, () => {
+ const rt = new RichText({
+ text: `Idk maybe a bot command`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ const match = hasMutedWord({
+ mutedWords: [{ value: `!command`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+ })
+
+ describe(`e/acc`, () => {
+ const rt = new RichText({
+ text: `I'm e/acc pilled`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: e/acc`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `e/acc`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: acc`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `acc`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`super-bad`, () => {
+ const rt = new RichText({
+ text: `I'm super-bad`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: super-bad`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `super-bad`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: super`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `super`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: super bad`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `super bad`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: superbad`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `superbad`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+ })
+
+ describe(`idk_what_this_would_be`, () => {
+ const rt = new RichText({
+ text: `Weird post with idk_what_this_would_be`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: idk what this would be`, () => {
+ const match = hasMutedWord({
+ mutedWords: [
+ { value: `idk what this would be`, targets: ['content'] },
+ ],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`no match: idk what this would be for`, () => {
+ // extra word
+ const match = hasMutedWord({
+ mutedWords: [
+ { value: `idk what this would be for`, targets: ['content'] },
+ ],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+
+ it(`match: idk`, () => {
+ // extra word
+ const match = hasMutedWord({
+ mutedWords: [{ value: `idk`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: idkwhatthiswouldbe`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `idkwhatthiswouldbe`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(false)
+ })
+ })
+
+ describe(`parentheses`, () => {
+ const rt = new RichText({
+ text: `Post with context(iykyk)`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: context(iykyk)`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `context(iykyk)`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: context`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `context`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: iykyk`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `iykyk`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: (iykyk)`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `(iykyk)`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+
+ describe(`🦋`, () => {
+ const rt = new RichText({
+ text: `Post with 🦋`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: 🦋`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: `🦋`, targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+ })
+
+ describe(`phrases`, () => {
+ describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
+ const rt = new RichText({
+ text: `I like turtles, or how I learned to stop worrying and love the internet.`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ it(`match: stop worrying`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'stop worrying', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+
+ it(`match: turtles, or how`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'turtles, or how', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+ })
+
+ describe(`languages without spaces`, () => {
+ // I love turtles, or how I learned to stop worrying and love the internet
+ describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
+ const rt = new RichText({
+ text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
+ })
+ rt.detectFacetsWithoutResolution()
+
+ // internet
+ it(`match: インターネット`, () => {
+ const match = hasMutedWord({
+ mutedWords: [{ value: 'インターネット', targets: ['content'] }],
+ text: rt.text,
+ facets: rt.facets,
+ outlineTags: [],
+ languages: ['ja'],
+ })
+
+ expect(match).toBe(true)
+ })
+ })
+ })
+
+ describe(`doesn't mute own post`, () => {
+ it(`does mute if it isn't own post`, () => {
+ const res = moderatePost(
+ mock.postView({
+ record: mock.post({
+ text: 'Mute words!',
+ }),
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [],
+ }),
+ {
+ userDid: 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: false,
+ labels: {},
+ labelers: [],
+ mutedWords: [{ value: 'words', targets: ['content'] }],
+ hiddenPosts: [],
+ },
+ labelDefs: {},
+ },
+ )
+ expect(res.causes[0].type).toBe('mute-word')
+ })
+
+ it(`doesn't mute own post when muted word is in text`, () => {
+ const res = moderatePost(
+ mock.postView({
+ record: mock.post({
+ text: 'Mute words!',
+ }),
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [],
+ }),
+ {
+ userDid: 'did:web:bob.test',
+ prefs: {
+ adultContentEnabled: false,
+ labels: {},
+ labelers: [],
+ mutedWords: [{ value: 'words', targets: ['content'] }],
+ hiddenPosts: [],
+ },
+ labelDefs: {},
+ },
+ )
+ expect(res.causes.length).toBe(0)
+ })
+
+ it(`doesn't mute own post when muted word is in tags`, () => {
+ const rt = new RichText({
+ text: `Mute #words!`,
+ })
+ const res = moderatePost(
+ mock.postView({
+ record: mock.post({
+ text: rt.text,
+ facets: rt.facets,
+ }),
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [],
+ }),
+ {
+ userDid: 'did:web:bob.test',
+ prefs: {
+ adultContentEnabled: false,
+ labels: {},
+ labelers: [],
+ mutedWords: [{ value: 'words', targets: ['tags'] }],
+ hiddenPosts: [],
+ },
+ labelDefs: {},
+ },
+ )
+ expect(res.causes.length).toBe(0)
+ })
+ })
+})
diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts
index d97a21941eb..0a9a768ce0c 100644
--- a/packages/api/tests/moderation-prefs.test.ts
+++ b/packages/api/tests/moderation-prefs.test.ts
@@ -1,5 +1,5 @@
import { TestNetworkNoAppView } from '@atproto/dev-env'
-import { BskyAgent, BSKY_MODSERVICE_DID, DEFAULT_LABEL_SETTINGS } from '..'
+import { BskyAgent, DEFAULT_LABEL_SETTINGS } from '..'
import './util/moderation-behavior'
describe('agent', () => {
@@ -28,7 +28,7 @@ describe('agent', () => {
preferences: [
{
$type: 'app.bsky.actor.defs#contentLabelPref',
- label: 'nsfw',
+ label: 'porn',
visibility: 'show',
},
{
@@ -38,12 +38,12 @@ describe('agent', () => {
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
- label: 'suggestive',
+ label: 'sexual',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
- label: 'gore',
+ label: 'graphic-media',
visibility: 'show',
},
],
@@ -53,7 +53,6 @@ describe('agent', () => {
pinned: undefined,
saved: undefined,
},
- hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
@@ -61,14 +60,11 @@ describe('agent', () => {
porn: 'ignore',
nudity: 'ignore',
sexual: 'ignore',
- gore: 'ignore',
+ 'graphic-media': 'ignore',
},
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ hiddenPosts: [],
+ mutedWords: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -80,13 +76,11 @@ describe('agent', () => {
hideReposts: false,
},
},
- mutedWords: [],
threadViewPrefs: {
prioritizeFollowedUsers: true,
sort: 'oldest',
},
})
- expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
})
it('adds/removes moderation services', async () => {
@@ -98,28 +92,22 @@ describe('agent', () => {
password: 'password',
})
- await agent.addModService('did:plc:other')
- expect(agent.labelersHeader).toStrictEqual([
- BSKY_MODSERVICE_DID,
- 'did:plc:other',
- ])
+ await agent.addLabeler('did:plc:other')
+ expect(agent.labelersHeader).toStrictEqual(['did:plc:other'])
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
- hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
+ labelers: [
{
did: 'did:plc:other',
labels: {},
},
],
+ hiddenPosts: [],
+ mutedWords: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -131,76 +119,24 @@ describe('agent', () => {
hideQuotePosts: false,
},
},
- mutedWords: [],
- threadViewPrefs: {
- sort: 'oldest',
- prioritizeFollowedUsers: true,
- },
- })
- expect(agent.labelersHeader).toStrictEqual([
- BSKY_MODSERVICE_DID,
- 'did:plc:other',
- ])
-
- await agent.removeModService('did:plc:other')
- expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
- await expect(agent.getPreferences()).resolves.toStrictEqual({
- feeds: { pinned: undefined, saved: undefined },
- hiddenPosts: [],
- interests: { tags: [] },
- moderationPrefs: {
- adultContentEnabled: false,
- labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
- },
- birthDate: undefined,
- feedViewPrefs: {
- home: {
- hideReplies: false,
- hideRepliesByUnfollowed: true,
- hideRepliesByLikeCount: 0,
- hideReposts: false,
- hideQuotePosts: false,
- },
- },
- mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
- expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
- })
-
- it('cant remove the default moderation service', async () => {
- const agent = new BskyAgent({ service: network.pds.url })
-
- await agent.createAccount({
- handle: 'user6.test',
- email: 'user6@test.com',
- password: 'password',
- })
+ expect(agent.labelersHeader).toStrictEqual(['did:plc:other'])
- await agent.removeModService(BSKY_MODSERVICE_DID)
- expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
+ await agent.removeLabeler('did:plc:other')
+ expect(agent.labelersHeader).toStrictEqual([])
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
- hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
- ],
+ labelers: [],
+ hiddenPosts: [],
+ mutedWords: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -212,13 +148,12 @@ describe('agent', () => {
hideQuotePosts: false,
},
},
- mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
- expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
+ expect(agent.labelersHeader).toStrictEqual([])
})
it('sets label preferences globally and per-moderator', async () => {
@@ -230,23 +165,18 @@ describe('agent', () => {
password: 'password',
})
- await agent.addModService('did:plc:other')
+ await agent.addLabeler('did:plc:other')
await agent.setContentLabelPref('porn', 'ignore')
await agent.setContentLabelPref('porn', 'hide', 'did:plc:other')
await agent.setContentLabelPref('x-custom', 'warn', 'did:plc:other')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
- hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
- labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' },
- mods: [
- {
- did: BSKY_MODSERVICE_DID,
- labels: {},
- },
+ labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', nsfw: 'ignore' },
+ labelers: [
{
did: 'did:plc:other',
labels: {
@@ -255,6 +185,8 @@ describe('agent', () => {
},
},
],
+ hiddenPosts: [],
+ mutedWords: [],
},
birthDate: undefined,
feedViewPrefs: {
@@ -266,11 +198,138 @@ describe('agent', () => {
hideQuotePosts: false,
},
},
- mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
})
+
+ it(`updates label pref`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user8.test',
+ email: 'user8@test.com',
+ password: 'password',
+ })
+
+ await agent.addLabeler('did:plc:other')
+ await agent.setContentLabelPref('porn', 'ignore')
+ await agent.setContentLabelPref('porn', 'ignore', 'did:plc:other')
+ await agent.setContentLabelPref('porn', 'hide')
+ await agent.setContentLabelPref('porn', 'hide', 'did:plc:other')
+
+ const { moderationPrefs } = await agent.getPreferences()
+ const labeler = moderationPrefs.labelers.find(
+ (l) => l.did === 'did:plc:other',
+ )
+
+ expect(moderationPrefs.labels.porn).toEqual('hide')
+ expect(labeler?.labels?.porn).toEqual('hide')
+ })
+
+ it(`double-write for legacy: 'graphic-media' in sync with 'gore'`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user9.test',
+ email: 'user9@test.com',
+ password: 'password',
+ })
+
+ await agent.setContentLabelPref('graphic-media', 'hide')
+ const a = await agent.getPreferences()
+
+ expect(a.moderationPrefs.labels.gore).toEqual('hide')
+ expect(a.moderationPrefs.labels['graphic-media']).toEqual('hide')
+
+ await agent.setContentLabelPref('graphic-media', 'warn')
+ const b = await agent.getPreferences()
+
+ expect(b.moderationPrefs.labels.gore).toEqual('warn')
+ expect(b.moderationPrefs.labels['graphic-media']).toEqual('warn')
+ })
+
+ it(`double-write for legacy: 'porn' in sync with 'nsfw'`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user10.test',
+ email: 'user10@test.com',
+ password: 'password',
+ })
+
+ await agent.setContentLabelPref('porn', 'hide')
+ const a = await agent.getPreferences()
+
+ expect(a.moderationPrefs.labels.nsfw).toEqual('hide')
+ expect(a.moderationPrefs.labels.porn).toEqual('hide')
+
+ await agent.setContentLabelPref('porn', 'warn')
+ const b = await agent.getPreferences()
+
+ expect(b.moderationPrefs.labels.nsfw).toEqual('warn')
+ expect(b.moderationPrefs.labels.porn).toEqual('warn')
+ })
+
+ it(`double-write for legacy: 'sexual' in sync with 'suggestive'`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user11.test',
+ email: 'user11@test.com',
+ password: 'password',
+ })
+
+ await agent.setContentLabelPref('sexual', 'hide')
+ const a = await agent.getPreferences()
+
+ expect(a.moderationPrefs.labels.sexual).toEqual('hide')
+ expect(a.moderationPrefs.labels.suggestive).toEqual('hide')
+
+ await agent.setContentLabelPref('sexual', 'warn')
+ const b = await agent.getPreferences()
+
+ expect(b.moderationPrefs.labels.sexual).toEqual('warn')
+ expect(b.moderationPrefs.labels.suggestive).toEqual('warn')
+ })
+
+ it(`double-write for legacy: filters out existing old label pref if double-written`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user12.test',
+ email: 'user12@test.com',
+ password: 'password',
+ })
+
+ await agent.setContentLabelPref('nsfw', 'hide')
+ await agent.setContentLabelPref('porn', 'hide')
+ const a = await agent.app.bsky.actor.getPreferences({})
+
+ const nsfwSettings = a.data.preferences.filter(
+ (pref) => pref.label === 'nsfw',
+ )
+ expect(nsfwSettings.length).toEqual(1)
+ })
+
+ it(`remaps old values to new on read`, async () => {
+ const agent = new BskyAgent({ service: network.pds.url })
+
+ await agent.createAccount({
+ handle: 'user13.test',
+ email: 'user13@test.com',
+ password: 'password',
+ })
+
+ await agent.setContentLabelPref('nsfw', 'hide')
+ await agent.setContentLabelPref('gore', 'hide')
+ await agent.setContentLabelPref('suggestive', 'hide')
+ const a = await agent.getPreferences()
+
+ expect(a.moderationPrefs.labels.porn).toEqual('hide')
+ expect(a.moderationPrefs.labels['graphic-media']).toEqual('hide')
+ expect(a.moderationPrefs.labels['sexual']).toEqual('hide')
+ })
})
diff --git a/packages/api/tests/moderation-quoteposts.test.ts b/packages/api/tests/moderation-quoteposts.test.ts
new file mode 100644
index 00000000000..b511a6be73b
--- /dev/null
+++ b/packages/api/tests/moderation-quoteposts.test.ts
@@ -0,0 +1,277 @@
+import {
+ moderateProfile,
+ moderatePost,
+ mock,
+ ModerationOpts,
+ InterpretedLabelValueDefinition,
+ interpretLabelValueDefinition,
+} from '../src'
+import './util/moderation-behavior'
+
+interface ScenarioResult {
+ profileList?: string[]
+ profileView?: string[]
+ avatar?: string[]
+ banner?: string[]
+ displayName?: string[]
+ contentList?: string[]
+ contentView?: string[]
+ contentMedia?: string[]
+}
+
+interface Scenario {
+ blurs: 'content' | 'media' | 'none'
+ severity: 'alert' | 'inform' | 'none'
+ account: ScenarioResult
+ profile: ScenarioResult
+ post: ScenarioResult
+}
+
+const TESTS: Scenario[] = [
+ {
+ blurs: 'content',
+ severity: 'alert',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'content',
+ severity: 'inform',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'content',
+ severity: 'none',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+
+ {
+ blurs: 'media',
+ severity: 'alert',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'media',
+ severity: 'inform',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'media',
+ severity: 'none',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+
+ {
+ blurs: 'none',
+ severity: 'alert',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'none',
+ severity: 'inform',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+ {
+ blurs: 'none',
+ severity: 'none',
+ account: {
+ profileList: ['filter'],
+ contentList: ['filter'],
+ },
+ profile: {},
+ post: {
+ contentList: ['filter'],
+ },
+ },
+]
+
+describe('Moderation: custom labels', () => {
+ const scenarios = TESTS.flatMap((test) => [
+ {
+ blurs: test.blurs,
+ severity: test.severity,
+ target: 'post',
+ expected: test.post,
+ },
+ {
+ blurs: test.blurs,
+ severity: test.severity,
+ target: 'profile',
+ expected: test.profile,
+ },
+ {
+ blurs: test.blurs,
+ severity: test.severity,
+ target: 'account',
+ expected: test.account,
+ },
+ ])
+ it.each(scenarios)(
+ 'blurs=$blurs, severity=$severity, target=$target',
+ ({ blurs, severity, target, expected }) => {
+ let postLabels
+ let profileLabels
+ if (target === 'post') {
+ postLabels = [
+ mock.label({
+ val: 'custom',
+ uri: 'at://did:web:carla.test/app.bsky.feed.post/fake',
+ src: 'did:web:labeler.test',
+ }),
+ ]
+ } else if (target === 'profile') {
+ profileLabels = [
+ mock.label({
+ val: 'custom',
+ uri: 'at://did:web:carla.test/app.bsky.actor.profile/self',
+ src: 'did:web:labeler.test',
+ }),
+ ]
+ } else {
+ profileLabels = [
+ mock.label({
+ val: 'custom',
+ uri: 'did:web:carla.test',
+ src: 'did:web:labeler.test',
+ }),
+ ]
+ }
+
+ const post = mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ embed: mock.embedRecordView({
+ record: mock.post({
+ text: 'Quoted post text',
+ }),
+ labels: postLabels,
+ author: mock.profileViewBasic({
+ handle: 'carla.test',
+ displayName: 'Carla',
+ labels: profileLabels,
+ }),
+ }),
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ })
+ const res = moderatePost(post, modOpts(blurs, severity))
+
+ expect(res.ui('profileList')).toBeModerationResult(
+ expected.profileList || [],
+ )
+ expect(res.ui('profileView')).toBeModerationResult(
+ expected.profileView || [],
+ )
+ expect(res.ui('avatar')).toBeModerationResult(expected.avatar || [])
+ expect(res.ui('banner')).toBeModerationResult(expected.banner || [])
+ expect(res.ui('displayName')).toBeModerationResult(
+ expected.displayName || [],
+ )
+ expect(res.ui('contentList')).toBeModerationResult(
+ expected.contentList || [],
+ )
+ expect(res.ui('contentView')).toBeModerationResult(
+ expected.contentView || [],
+ )
+ expect(res.ui('contentMedia')).toBeModerationResult(
+ expected.contentMedia || [],
+ )
+ },
+ )
+})
+
+function modOpts(blurs: string, severity: string): ModerationOpts {
+ return {
+ userDid: 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: true,
+ labels: {},
+ labelers: [
+ {
+ did: 'did:web:labeler.test',
+ labels: { custom: 'hide' },
+ },
+ ],
+ mutedWords: [],
+ hiddenPosts: [],
+ },
+ labelDefs: {
+ 'did:web:labeler.test': [makeCustomLabel(blurs, severity)],
+ },
+ }
+}
+
+function makeCustomLabel(
+ blurs: string,
+ severity: string,
+): InterpretedLabelValueDefinition {
+ return interpretLabelValueDefinition(
+ {
+ identifier: 'custom',
+ blurs,
+ severity,
+ defaultSetting: 'warn',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ )
+}
diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts
index 4a010c41fff..320e1b105d0 100644
--- a/packages/api/tests/moderation.test.ts
+++ b/packages/api/tests/moderation.test.ts
@@ -29,7 +29,7 @@ describe('Moderation', () => {
labels: {
porn: 'hide',
},
- mods: [],
+ labelers: [],
},
},
)
@@ -61,7 +61,7 @@ describe('Moderation', () => {
labels: {
porn: 'ignore',
},
- mods: [],
+ labelers: [],
},
},
)
@@ -95,7 +95,7 @@ describe('Moderation', () => {
labels: {
porn: 'hide',
},
- mods: [],
+ labelers: [],
},
},
)
@@ -137,7 +137,7 @@ describe('Moderation', () => {
labels: {
porn: 'ignore',
},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: { porn: 'ignore' },
@@ -182,7 +182,7 @@ describe('Moderation', () => {
prefs: {
adultContentEnabled: true,
labels: {},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: {},
@@ -232,7 +232,7 @@ describe('Moderation', () => {
labels: {
porn: 'hide',
},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: {},
@@ -253,7 +253,7 @@ describe('Moderation', () => {
prefs: {
adultContentEnabled: true,
labels: { porn: 'warn' },
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: { porn: 'warn' },
@@ -262,13 +262,15 @@ describe('Moderation', () => {
},
labelDefs: {
'did:web:labeler.test': [
- interpretLabelValueDefinition({
- identifier: 'porn',
- blurs: 'none',
- severity: 'inform',
- defaultSetting: 'warn',
- locales: [],
- }),
+ interpretLabelValueDefinition(
+ {
+ identifier: 'porn',
+ blurs: 'none',
+ severity: 'inform',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
],
},
}
@@ -309,7 +311,7 @@ describe('Moderation', () => {
prefs: {
adultContentEnabled: true,
labels: {},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: {},
@@ -318,13 +320,15 @@ describe('Moderation', () => {
},
labelDefs: {
'did:web:labeler.test': [
- interpretLabelValueDefinition({
- identifier: '!hide',
- blurs: 'none',
- severity: 'inform',
- defaultSetting: 'warn',
- locales: [],
- }),
+ interpretLabelValueDefinition(
+ {
+ identifier: '!hide',
+ blurs: 'none',
+ severity: 'inform',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
],
},
}
@@ -350,7 +354,7 @@ describe('Moderation', () => {
modOpts,
)
- expect(res.ui('profileList')).toBeModerationResult(['filter'])
+ expect(res.ui('profileList')).toBeModerationResult([])
expect(res.ui('profileView')).toBeModerationResult([])
expect(res.ui('avatar')).toBeModerationResult([])
expect(res.ui('banner')).toBeModerationResult([])
@@ -370,7 +374,7 @@ describe('Moderation', () => {
prefs: {
adultContentEnabled: true,
labels: {},
- mods: [
+ labelers: [
{
did: 'did:web:labeler.test',
labels: { BadLabel: 'hide', 'bad/label': 'hide' },
@@ -379,20 +383,24 @@ describe('Moderation', () => {
},
labelDefs: {
'did:web:labeler.test': [
- interpretLabelValueDefinition({
- identifier: 'BadLabel',
- blurs: 'content',
- severity: 'inform',
- defaultSetting: 'warn',
- locales: [],
- }),
- interpretLabelValueDefinition({
- identifier: 'bad/label',
- blurs: 'content',
- severity: 'inform',
- defaultSetting: 'warn',
- locales: [],
- }),
+ interpretLabelValueDefinition(
+ {
+ identifier: 'BadLabel',
+ blurs: 'content',
+ severity: 'inform',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
+ interpretLabelValueDefinition(
+ {
+ identifier: 'bad/label',
+ blurs: 'content',
+ severity: 'inform',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
],
},
}
@@ -433,4 +441,260 @@ describe('Moderation', () => {
expect(res.ui('contentView')).toBeModerationResult([])
expect(res.ui('contentMedia')).toBeModerationResult([])
})
+
+ it('Custom labels can set the default setting', () => {
+ const modOpts = {
+ userDid: 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: true,
+ labels: {},
+ labelers: [
+ {
+ did: 'did:web:labeler.test',
+ labels: {},
+ },
+ ],
+ },
+ labelDefs: {
+ 'did:web:labeler.test': [
+ interpretLabelValueDefinition(
+ {
+ identifier: 'default-hide',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
+ interpretLabelValueDefinition(
+ {
+ identifier: 'default-warn',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'warn',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
+ interpretLabelValueDefinition(
+ {
+ identifier: 'default-ignore',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'ignore',
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
+ ],
+ },
+ }
+ const res1 = moderatePost(
+ mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [
+ {
+ src: 'did:web:labeler.test',
+ uri: 'at://did:web:bob.test/app.bsky.post/fake',
+ val: 'default-hide',
+ cts: new Date().toISOString(),
+ },
+ ],
+ }),
+ modOpts,
+ )
+
+ expect(res1.ui('profileList')).toBeModerationResult([])
+ expect(res1.ui('profileView')).toBeModerationResult([])
+ expect(res1.ui('avatar')).toBeModerationResult([])
+ expect(res1.ui('banner')).toBeModerationResult([])
+ expect(res1.ui('displayName')).toBeModerationResult([])
+ expect(res1.ui('contentList')).toBeModerationResult(['filter', 'blur'])
+ expect(res1.ui('contentView')).toBeModerationResult(['inform'])
+ expect(res1.ui('contentMedia')).toBeModerationResult([])
+
+ const res2 = moderatePost(
+ mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [
+ {
+ src: 'did:web:labeler.test',
+ uri: 'at://did:web:bob.test/app.bsky.post/fake',
+ val: 'default-warn',
+ cts: new Date().toISOString(),
+ },
+ ],
+ }),
+ modOpts,
+ )
+
+ expect(res2.ui('profileList')).toBeModerationResult([])
+ expect(res2.ui('profileView')).toBeModerationResult([])
+ expect(res2.ui('avatar')).toBeModerationResult([])
+ expect(res2.ui('banner')).toBeModerationResult([])
+ expect(res2.ui('displayName')).toBeModerationResult([])
+ expect(res2.ui('contentList')).toBeModerationResult(['blur'])
+ expect(res2.ui('contentView')).toBeModerationResult(['inform'])
+ expect(res2.ui('contentMedia')).toBeModerationResult([])
+
+ const res3 = moderatePost(
+ mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [
+ {
+ src: 'did:web:labeler.test',
+ uri: 'at://did:web:bob.test/app.bsky.post/fake',
+ val: 'default-ignore',
+ cts: new Date().toISOString(),
+ },
+ ],
+ }),
+ modOpts,
+ )
+
+ expect(res3.ui('profileList')).toBeModerationResult([])
+ expect(res3.ui('profileView')).toBeModerationResult([])
+ expect(res3.ui('avatar')).toBeModerationResult([])
+ expect(res3.ui('banner')).toBeModerationResult([])
+ expect(res3.ui('displayName')).toBeModerationResult([])
+ expect(res3.ui('contentList')).toBeModerationResult([])
+ expect(res3.ui('contentView')).toBeModerationResult([])
+ expect(res3.ui('contentMedia')).toBeModerationResult([])
+ })
+
+ it('Custom labels can require adult content to be enabled', () => {
+ const modOpts = {
+ userDid: 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: false,
+ labels: { adult: 'ignore' },
+ labelers: [
+ {
+ did: 'did:web:labeler.test',
+ labels: {
+ adult: 'ignore',
+ },
+ },
+ ],
+ },
+ labelDefs: {
+ 'did:web:labeler.test': [
+ interpretLabelValueDefinition(
+ {
+ identifier: 'adult',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ adultOnly: true,
+ locales: [],
+ },
+ 'did:web:labeler.test',
+ ),
+ ],
+ },
+ }
+ const res = moderatePost(
+ mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [
+ {
+ src: 'did:web:labeler.test',
+ uri: 'at://did:web:bob.test/app.bsky.post/fake',
+ val: 'adult',
+ cts: new Date().toISOString(),
+ },
+ ],
+ }),
+ modOpts,
+ )
+
+ expect(res.ui('profileList')).toBeModerationResult([])
+ expect(res.ui('profileView')).toBeModerationResult([])
+ expect(res.ui('avatar')).toBeModerationResult([])
+ expect(res.ui('banner')).toBeModerationResult([])
+ expect(res.ui('displayName')).toBeModerationResult([])
+ expect(res.ui('contentList')).toBeModerationResult([
+ 'filter',
+ 'blur',
+ 'noOverride',
+ ])
+ expect(res.ui('contentView')).toBeModerationResult(['blur', 'noOverride'])
+ expect(res.ui('contentMedia')).toBeModerationResult([])
+ })
+
+ it('Adult content disabled forces the preference to hide', () => {
+ const modOpts = {
+ userDid: 'did:web:alice.test',
+ prefs: {
+ adultContentEnabled: false,
+ labels: { porn: 'ignore' },
+ labelers: [
+ {
+ did: 'did:web:labeler.test',
+ labels: {},
+ },
+ ],
+ },
+ labelDefs: {},
+ }
+ const res = moderatePost(
+ mock.postView({
+ record: {
+ text: 'Hello',
+ createdAt: new Date().toISOString(),
+ },
+ author: mock.profileViewBasic({
+ handle: 'bob.test',
+ displayName: 'Bob',
+ }),
+ labels: [
+ {
+ src: 'did:web:labeler.test',
+ uri: 'at://did:web:bob.test/app.bsky.post/fake',
+ val: 'porn',
+ cts: new Date().toISOString(),
+ },
+ ],
+ }),
+ modOpts,
+ )
+
+ expect(res.ui('profileList')).toBeModerationResult([])
+ expect(res.ui('profileView')).toBeModerationResult([])
+ expect(res.ui('avatar')).toBeModerationResult([])
+ expect(res.ui('banner')).toBeModerationResult([])
+ expect(res.ui('displayName')).toBeModerationResult([])
+ expect(res.ui('contentList')).toBeModerationResult(['filter'])
+ expect(res.ui('contentView')).toBeModerationResult([])
+ expect(res.ui('contentMedia')).toBeModerationResult(['blur', 'noOverride'])
+ })
})
diff --git a/packages/api/tests/util/moderation-behavior.ts b/packages/api/tests/util/moderation-behavior.ts
index 07c8310a4d2..0f33ec65b7e 100644
--- a/packages/api/tests/util/moderation-behavior.ts
+++ b/packages/api/tests/util/moderation-behavior.ts
@@ -254,12 +254,14 @@ export class ModerationBehaviorSuiteRunner {
this.configurations[scenario.cfg]?.adultContentEnabled,
),
labels: this.configurations[scenario.cfg].settings || {},
- mods: [
+ labelers: [
{
did: 'did:plc:fake-labeler',
labels: {},
},
],
+ mutedWords: [],
+ hiddenPosts: [],
},
}
}
diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts
index 810d5ea03a2..7407c3a961c 100644
--- a/packages/bsky/src/lexicon/lexicons.ts
+++ b/packages/bsky/src/lexicon/lexicons.ts
@@ -855,6 +855,17 @@ export const schemaDict = {
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
knownValues: ['content', 'media', 'none'],
},
+ defaultSetting: {
+ type: 'string',
+ description: 'The default setting for this label.',
+ knownValues: ['ignore', 'warn', 'hide'],
+ default: 'warn',
+ },
+ adultOnly: {
+ type: 'boolean',
+ description:
+ 'Does the user need to have adult content enabled in order to configure this label?',
+ },
locales: {
type: 'array',
items: {
@@ -3958,20 +3969,20 @@ export const schemaDict = {
},
},
},
- modsPref: {
+ labelersPref: {
type: 'object',
- required: ['mods'],
+ required: ['labelers'],
properties: {
- mods: {
+ labelers: {
type: 'array',
items: {
type: 'ref',
- ref: 'lex:app.bsky.actor.defs#modPrefItem',
+ ref: 'lex:app.bsky.actor.defs#labelerPrefItem',
},
},
},
},
- modPrefItem: {
+ labelerPrefItem: {
type: 'object',
required: ['did'],
properties: {
diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts
index bf2d045f093..7bd87c6e953 100644
--- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts
+++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts
@@ -338,36 +338,36 @@ export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
-export interface ModsPref {
- mods: ModPrefItem[]
+export interface LabelersPref {
+ labelers: LabelerPrefItem[]
[k: string]: unknown
}
-export function isModsPref(v: unknown): v is ModsPref {
+export function isLabelersPref(v: unknown): v is LabelersPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modsPref'
+ v.$type === 'app.bsky.actor.defs#labelersPref'
)
}
-export function validateModsPref(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modsPref', v)
+export function validateLabelersPref(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelersPref', v)
}
-export interface ModPrefItem {
+export interface LabelerPrefItem {
did: string
[k: string]: unknown
}
-export function isModPrefItem(v: unknown): v is ModPrefItem {
+export function isLabelerPrefItem(v: unknown): v is LabelerPrefItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modPrefItem'
+ v.$type === 'app.bsky.actor.defs#labelerPrefItem'
)
}
-export function validateModPrefItem(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modPrefItem', v)
+export function validateLabelerPrefItem(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelerPrefItem', v)
}
diff --git a/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts
index 1af8b0f3890..d0225540a54 100644
--- a/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts
+++ b/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts
@@ -86,6 +86,10 @@ export interface LabelValueDefinition {
severity: 'inform' | 'alert' | 'none' | (string & {})
/** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */
blurs: 'content' | 'media' | 'none' | (string & {})
+ /** The default setting for this label. */
+ defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {})
+ /** Does the user need to have adult content enabled in order to configure this label? */
+ adultOnly?: boolean
locales: LabelValueDefinitionStrings[]
[k: string]: unknown
}
diff --git a/packages/bsky/tests/label-hydration.test.ts b/packages/bsky/tests/label-hydration.test.ts
index ec1fcb92c07..236fbac1e7b 100644
--- a/packages/bsky/tests/label-hydration.test.ts
+++ b/packages/bsky/tests/label-hydration.test.ts
@@ -1,5 +1,6 @@
import { AtpAgent } from '@atproto/api'
import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env'
+import axios from 'axios'
describe('label hydration', () => {
let network: TestNetwork
@@ -38,13 +39,12 @@ describe('label hydration', () => {
})
it('hydrates labels based on a supplied labeler header', async () => {
+ AtpAgent.configure({ appLabelers: [alice] })
+ pdsAgent.configureLabelersHeader([])
const res = await pdsAgent.api.app.bsky.actor.getProfile(
{ actor: carol },
{
- headers: {
- ...sc.getHeaders(bob),
- 'atproto-accept-labelers': `${alice};redact`,
- },
+ headers: sc.getHeaders(bob),
},
)
expect(res.data.labels?.length).toBe(1)
@@ -54,13 +54,13 @@ describe('label hydration', () => {
})
it('hydrates labels based on multiple a supplied labelers', async () => {
+ AtpAgent.configure({ appLabelers: [bob] })
+ pdsAgent.configureLabelersHeader([alice, labelerDid])
+
const res = await pdsAgent.api.app.bsky.actor.getProfile(
{ actor: carol },
{
- headers: {
- ...sc.getHeaders(bob),
- 'atproto-accept-labelers': `${alice},${bob};redact, ${labelerDid}`,
- },
+ headers: sc.getHeaders(bob),
},
)
expect(res.data.labels?.length).toBe(3)
@@ -78,8 +78,8 @@ describe('label hydration', () => {
})
it('defaults to service labels when no labeler header is provided', async () => {
- const res = await pdsAgent.api.app.bsky.actor.getProfile(
- { actor: carol },
+ const res = await axios.get(
+ `${network.pds.url}/xrpc/app.bsky.actor.getProfile?actor=${carol}`,
{ headers: sc.getHeaders(bob) },
)
expect(res.data.labels?.length).toBe(1)
@@ -94,6 +94,9 @@ describe('label hydration', () => {
})
it('hydrates labels onto list views.', async () => {
+ AtpAgent.configure({ appLabelers: [labelerDid] })
+ pdsAgent.configureLabelersHeader([])
+
const list = await pdsAgent.api.app.bsky.graph.list.create(
{ repo: alice },
{
diff --git a/packages/bsky/tests/views/takedown-labels.test.ts b/packages/bsky/tests/views/takedown-labels.test.ts
index b7118c75a34..399afb35e82 100644
--- a/packages/bsky/tests/views/takedown-labels.test.ts
+++ b/packages/bsky/tests/views/takedown-labels.test.ts
@@ -58,6 +58,7 @@ describe('bsky takedown labels', () => {
neg: false,
cts,
}))
+ AtpAgent.configure({ appLabelers: [src] })
await network.bsky.db.db.insertInto('label').values(labels).execute()
})
@@ -123,12 +124,10 @@ describe('bsky takedown labels', () => {
})
it('only applies if the relevant labeler is configured', async () => {
- const res = await agent.api.app.bsky.actor.getProfile(
- {
- actor: sc.dids.carol,
- },
- { headers: { 'atproto-accept-labelers': 'did:web:example.com' } },
- )
+ AtpAgent.configure({ appLabelers: ['did:web:example.com'] })
+ const res = await agent.api.app.bsky.actor.getProfile({
+ actor: sc.dids.carol,
+ })
expect(res.data.did).toEqual(sc.dids.carol)
})
})
diff --git a/packages/bsky/tests/views/timeline.test.ts b/packages/bsky/tests/views/timeline.test.ts
index f697f02e033..03e865f7ed2 100644
--- a/packages/bsky/tests/views/timeline.test.ts
+++ b/packages/bsky/tests/views/timeline.test.ts
@@ -1,6 +1,11 @@
import assert from 'assert'
import AtpAgent from '@atproto/api'
-import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env'
+import {
+ TestNetwork,
+ SeedClient,
+ basicSeed,
+ EXAMPLE_LABELER,
+} from '@atproto/dev-env'
import { forSnapshot, getOriginator, paginateAll } from '../_util'
import { FeedViewPost } from '../../src/lexicon/types/app/bsky/feed/defs'
import { Database } from '../../src'
@@ -258,7 +263,7 @@ const createLabel = async (
val: opts.val,
cts: new Date().toISOString(),
neg: false,
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
})
.execute()
}
diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts
index 81d2caf6775..d0019865e77 100644
--- a/packages/dev-env/src/bsky.ts
+++ b/packages/dev-env/src/bsky.ts
@@ -5,7 +5,7 @@ import { AtpAgent } from '@atproto/api'
import { Secp256k1Keypair } from '@atproto/crypto'
import { Client as PlcClient } from '@did-plc/lib'
import { BskyConfig } from './types'
-import { ADMIN_PASSWORD } from './const'
+import { ADMIN_PASSWORD, EXAMPLE_LABELER } from './const'
import { BackgroundQueue } from '@atproto/bsky/src/data-plane/server/background'
export class TestBsky {
@@ -62,7 +62,7 @@ export class TestBsky {
bsyncHttpVersion: '1.1',
courierUrl: 'https://fake.example',
modServiceDid: cfg.modServiceDid ?? 'did:example:invalidMod',
- labelsFromIssuerDids: ['did:example:labeler'], // this did is also used as the labeler in seeds
+ labelsFromIssuerDids: [EXAMPLE_LABELER],
...cfg,
adminPasswords: [ADMIN_PASSWORD],
})
@@ -104,7 +104,7 @@ export class TestBsky {
getClient() {
const agent = new AtpAgent({ service: this.url })
- agent.configureLabelersHeader([])
+ agent.configureLabelersHeader([EXAMPLE_LABELER])
return agent
}
diff --git a/packages/dev-env/src/const.ts b/packages/dev-env/src/const.ts
index afa11ed4aad..97c0b5a2c42 100644
--- a/packages/dev-env/src/const.ts
+++ b/packages/dev-env/src/const.ts
@@ -1,2 +1,3 @@
export const ADMIN_PASSWORD = 'admin-pass'
export const JWT_SECRET = 'jwt-secret'
+export const EXAMPLE_LABELER = 'did:example:labeler'
diff --git a/packages/dev-env/src/index.ts b/packages/dev-env/src/index.ts
index d3b458c55eb..4f81340a5d3 100644
--- a/packages/dev-env/src/index.ts
+++ b/packages/dev-env/src/index.ts
@@ -10,3 +10,4 @@ export * from './seed'
export * from './moderator-client'
export * from './types'
export * from './util'
+export * from './const'
diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts
index ed222a55927..be0efc138f9 100644
--- a/packages/dev-env/src/mock/index.ts
+++ b/packages/dev-env/src/mock/index.ts
@@ -5,7 +5,7 @@ import {
REASONSPAM,
REASONOTHER,
} from '@atproto/api/src/client/types/com/atproto/moderation/defs'
-import { TestNetwork } from '../index'
+import { EXAMPLE_LABELER, TestNetwork } from '../index'
import { postTexts, replyTexts } from './data'
import labeledImgB64 from './img/labeled-img-b64'
import blurHashB64 from './img/blur-hash-avatar-b64'
@@ -349,29 +349,457 @@ export async function generateMockSetup(env: TestNetwork) {
},
)
- await alice.agent.api.app.bsky.labeler.service.create(
- { repo: alice.did, rkey: 'self' },
- {
- displayName: 'alices labels',
- description: 'Stopping spam and scams across the Atmosphere.',
- avatar: avatarRes.data.blob,
- policies: {
- reportReasons: [
- 'com.atproto.moderation.defs#reasonSpam',
- 'com.atproto.moderation.defs#reasonViolation',
- 'com.atproto.moderation.defs#reasonMisleading',
- ],
- labelValues: ['spam', '!hide', 'scam', 'intolerant'],
+ // create the dev-env moderator
+ {
+ const res = await clients.loggedout.api.com.atproto.server.createAccount({
+ email: 'mod-authority@test.com',
+ handle: 'mod-authority.test',
+ password: 'hunter2',
+ })
+ const agent = env.pds.getClient()
+ agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
+ await agent.api.app.bsky.actor.profile.create(
+ { repo: res.data.did },
+ {
+ displayName: 'Dev-env Moderation',
+ description: `The pretend version of mod.bsky.app`,
},
- createdAt: date.next().value,
- },
- )
- await createLabel(env.bsky.db, {
- uri: bob.did,
- cid: '',
- val: 'spam',
- src: alice.did,
- })
+ )
+
+ await agent.api.app.bsky.labeler.service.create(
+ { repo: res.data.did, rkey: 'self' },
+ {
+ policies: {
+ labelValues: [
+ '!hide',
+ '!warn',
+ 'porn',
+ 'sexual',
+ 'nudity',
+ 'sexual-figurative',
+ 'graphic-media',
+ 'self-harm',
+ 'sensitive',
+ 'extremist',
+ 'intolerant',
+ 'threat',
+ 'rude',
+ 'illicit',
+ 'security',
+ 'unsafe-link',
+ 'impersonation',
+ 'misinformation',
+ 'scam',
+ 'engagement-farming',
+ 'spam',
+ 'rumor',
+ 'misleading',
+ 'inauthentic',
+ ],
+ labelValueDefinitions: [
+ {
+ identifier: 'spam',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Spam',
+ description:
+ 'Unwanted, repeated, or unrelated actions that bother users.',
+ },
+ ],
+ },
+ {
+ identifier: 'impersonation',
+ blurs: 'none',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Impersonation',
+ description:
+ 'Pretending to be someone else without permission.',
+ },
+ ],
+ },
+ {
+ identifier: 'scam',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Scam',
+ description: 'Scams, phishing & fraud.',
+ },
+ ],
+ },
+ {
+ identifier: 'intolerant',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Intolerance',
+ description: 'Discrimination against protected groups.',
+ },
+ ],
+ },
+ {
+ identifier: 'self-harm',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Self-Harm',
+ description:
+ 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.',
+ },
+ ],
+ },
+ {
+ identifier: 'security',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Security Concerns',
+ description:
+ 'May be unsafe and could harm your device, steal your info, or get your account hacked.',
+ },
+ ],
+ },
+ {
+ identifier: 'misleading',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Misleading',
+ description:
+ 'Altered images/videos, deceptive links, or false statements.',
+ },
+ ],
+ },
+ {
+ identifier: 'threat',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Threats',
+ description:
+ 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.',
+ },
+ ],
+ },
+ {
+ identifier: 'unsafe-link',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Unsafe link',
+ description:
+ 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.',
+ },
+ ],
+ },
+ {
+ identifier: 'illicit',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Illicit',
+ description:
+ 'Promoting or selling potentially illicit goods, services, or activities.',
+ },
+ ],
+ },
+ {
+ identifier: 'misinformation',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Misinformation',
+ description:
+ 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.',
+ },
+ ],
+ },
+ {
+ identifier: 'rumor',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Rumor',
+ description:
+ 'Approach with caution, as these claims lack evidence from credible sources.',
+ },
+ ],
+ },
+ {
+ identifier: 'rude',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Rude',
+ description:
+ 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.',
+ },
+ ],
+ },
+ {
+ identifier: 'extremist',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Extremist',
+ description:
+ 'Radical views advocating violence, hate, or discrimination against individuals or groups.',
+ },
+ ],
+ },
+ {
+ identifier: 'sensitive',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Sensitive',
+ description:
+ 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.',
+ },
+ ],
+ },
+ {
+ identifier: 'engagement-farming',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Engagement Farming',
+ description:
+ 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.',
+ },
+ ],
+ },
+ {
+ identifier: 'inauthentic',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'hide',
+ adultOnly: false,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Inauthentic Account',
+ description: 'Bot or a person pretending to be someone else.',
+ },
+ ],
+ },
+ {
+ identifier: 'sexual-figurative',
+ blurs: 'media',
+ severity: 'none',
+ defaultSetting: 'show',
+ adultOnly: true,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Sexually Suggestive (Cartoon)',
+ description:
+ 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.',
+ },
+ ],
+ },
+ ],
+ },
+ createdAt: date.next().value,
+ },
+ )
+ }
+
+ // create a labeler account
+ {
+ const res = await clients.loggedout.api.com.atproto.server.createAccount({
+ email: 'labeler@test.com',
+ handle: 'labeler.test',
+ password: 'hunter2',
+ })
+ const agent = env.pds.getClient()
+ agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
+ await agent.api.app.bsky.actor.profile.create(
+ { repo: res.data.did },
+ {
+ displayName: 'Test Labeler',
+ description: `Labeling things across the atmosphere`,
+ },
+ )
+
+ await agent.api.app.bsky.labeler.service.create(
+ { repo: res.data.did, rkey: 'self' },
+ {
+ policies: {
+ labelValues: [
+ '!hide',
+ 'porn',
+ 'rude',
+ 'spam',
+ 'spider',
+ 'misinfo',
+ 'cool',
+ 'curate',
+ ],
+ labelValueDefinitions: [
+ {
+ identifier: 'rude',
+ blurs: 'content',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ adultOnly: true,
+ locales: [
+ {
+ lang: 'en',
+ name: 'Rude',
+ description: 'Just such a jerk, you wouldnt believe it.',
+ },
+ ],
+ },
+ {
+ identifier: 'spam',
+ blurs: 'content',
+ severity: 'inform',
+ defaultSetting: 'hide',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Spam',
+ description:
+ 'Low quality posts that dont add to the conversation.',
+ },
+ ],
+ },
+ {
+ identifier: 'spider',
+ blurs: 'media',
+ severity: 'alert',
+ defaultSetting: 'warn',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Spider!',
+ description: 'Oh no its a spider.',
+ },
+ ],
+ },
+ {
+ identifier: 'cool',
+ blurs: 'none',
+ severity: 'inform',
+ defaultSetting: 'warn',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Cool',
+ description: 'The coolest peeps in the atmosphere.',
+ },
+ ],
+ },
+ {
+ identifier: 'curate',
+ blurs: 'none',
+ severity: 'none',
+ defaultSetting: 'warn',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Curation filter',
+ description: 'We just dont want to see it as much.',
+ },
+ ],
+ },
+ ],
+ },
+ createdAt: date.next().value,
+ },
+ )
+ await createLabel(env.bsky.db, {
+ uri: alice.did,
+ cid: '',
+ val: 'rude',
+ src: res.data.did,
+ })
+ await createLabel(env.bsky.db, {
+ uri: `at://${alice.did}/app.bsky.feed.generator/alice-favs`,
+ cid: '',
+ val: 'cool',
+ src: res.data.did,
+ })
+ await createLabel(env.bsky.db, {
+ uri: bob.did,
+ cid: '',
+ val: 'cool',
+ src: res.data.did,
+ })
+ await createLabel(env.bsky.db, {
+ uri: carla.did,
+ cid: '',
+ val: 'spam',
+ src: res.data.did,
+ })
+ }
}
function ucfirst(str: string): string {
@@ -390,7 +818,7 @@ const createLabel = async (
val: opts.val,
cts: new Date().toISOString(),
neg: false,
- src: opts.src ?? 'did:example:labeler',
+ src: opts.src ?? EXAMPLE_LABELER,
})
.execute()
}
diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts
index d7da4df79ab..11d18e24224 100644
--- a/packages/dev-env/src/network.ts
+++ b/packages/dev-env/src/network.ts
@@ -11,6 +11,7 @@ import { TestOzone, createOzoneDid } from './ozone'
import { mockNetworkUtilities } from './util'
import { TestNetworkNoAppView } from './network-no-appview'
import { Secp256k1Keypair } from '@atproto/crypto'
+import { EXAMPLE_LABELER } from './const'
const ADMIN_USERNAME = 'admin'
const ADMIN_PASSWORD = 'admin-pass'
@@ -53,7 +54,7 @@ export class TestNetwork extends TestNetworkNoAppView {
dbPostgresUrl,
redisHost,
modServiceDid: ozoneDid,
- labelsFromIssuerDids: [ozoneDid, 'did:example:labeler'], // this did is also used as the labeler in seeds
+ labelsFromIssuerDids: [ozoneDid, EXAMPLE_LABELER],
...params.bsky,
})
diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts
index d1b3cbbc330..0eb47c3f625 100644
--- a/packages/dev-env/src/pds.ts
+++ b/packages/dev-env/src/pds.ts
@@ -8,7 +8,7 @@ import { createSecretKeyObject } from '@atproto/pds/src/auth-verifier'
import { Secp256k1Keypair, randomStr } from '@atproto/crypto'
import { AtpAgent } from '@atproto/api'
import { PdsConfig } from './types'
-import { ADMIN_PASSWORD, JWT_SECRET } from './const'
+import { ADMIN_PASSWORD, EXAMPLE_LABELER, JWT_SECRET } from './const'
export class TestPds {
constructor(
@@ -63,7 +63,7 @@ export class TestPds {
getClient(): AtpAgent {
const agent = new AtpAgent({ service: this.url })
- agent.configureLabelersHeader([])
+ agent.configureLabelersHeader([EXAMPLE_LABELER])
return agent
}
diff --git a/packages/dev-env/src/seed/basic.ts b/packages/dev-env/src/seed/basic.ts
index 45583813afb..40d988c6cac 100644
--- a/packages/dev-env/src/seed/basic.ts
+++ b/packages/dev-env/src/seed/basic.ts
@@ -3,6 +3,7 @@ import { TestBsky } from '../bsky'
import { TestNetwork } from '../network'
import { TestNetworkNoAppView } from '../network-no-appview'
import { SeedClient } from './client'
+import { EXAMPLE_LABELER } from '../const'
export default async (
sc: SeedClient,
@@ -182,7 +183,7 @@ const createLabel = async (
val: opts.val,
cts: new Date().toISOString(),
neg: false,
- src: 'did:example:labeler', // this did is also configured on labelsFromIssuerDids
+ src: EXAMPLE_LABELER, // this did is also configured on labelsFromIssuerDids
})
.execute()
}
diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts
index 5a45c31b922..c1f266c1ecf 100644
--- a/packages/ozone/src/lexicon/lexicons.ts
+++ b/packages/ozone/src/lexicon/lexicons.ts
@@ -855,6 +855,17 @@ export const schemaDict = {
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
knownValues: ['content', 'media', 'none'],
},
+ defaultSetting: {
+ type: 'string',
+ description: 'The default setting for this label.',
+ knownValues: ['ignore', 'warn', 'hide'],
+ default: 'warn',
+ },
+ adultOnly: {
+ type: 'boolean',
+ description:
+ 'Does the user need to have adult content enabled in order to configure this label?',
+ },
locales: {
type: 'array',
items: {
@@ -3958,20 +3969,20 @@ export const schemaDict = {
},
},
},
- modsPref: {
+ labelersPref: {
type: 'object',
- required: ['mods'],
+ required: ['labelers'],
properties: {
- mods: {
+ labelers: {
type: 'array',
items: {
type: 'ref',
- ref: 'lex:app.bsky.actor.defs#modPrefItem',
+ ref: 'lex:app.bsky.actor.defs#labelerPrefItem',
},
},
},
},
- modPrefItem: {
+ labelerPrefItem: {
type: 'object',
required: ['did'],
properties: {
diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts
index bf2d045f093..7bd87c6e953 100644
--- a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts
+++ b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts
@@ -338,36 +338,36 @@ export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
-export interface ModsPref {
- mods: ModPrefItem[]
+export interface LabelersPref {
+ labelers: LabelerPrefItem[]
[k: string]: unknown
}
-export function isModsPref(v: unknown): v is ModsPref {
+export function isLabelersPref(v: unknown): v is LabelersPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modsPref'
+ v.$type === 'app.bsky.actor.defs#labelersPref'
)
}
-export function validateModsPref(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modsPref', v)
+export function validateLabelersPref(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelersPref', v)
}
-export interface ModPrefItem {
+export interface LabelerPrefItem {
did: string
[k: string]: unknown
}
-export function isModPrefItem(v: unknown): v is ModPrefItem {
+export function isLabelerPrefItem(v: unknown): v is LabelerPrefItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modPrefItem'
+ v.$type === 'app.bsky.actor.defs#labelerPrefItem'
)
}
-export function validateModPrefItem(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modPrefItem', v)
+export function validateLabelerPrefItem(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelerPrefItem', v)
}
diff --git a/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts
index 1af8b0f3890..d0225540a54 100644
--- a/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts
+++ b/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts
@@ -86,6 +86,10 @@ export interface LabelValueDefinition {
severity: 'inform' | 'alert' | 'none' | (string & {})
/** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */
blurs: 'content' | 'media' | 'none' | (string & {})
+ /** The default setting for this label. */
+ defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {})
+ /** Does the user need to have adult content enabled in order to configure this label? */
+ adultOnly?: boolean
locales: LabelValueDefinitionStrings[]
[k: string]: unknown
}
diff --git a/packages/ozone/tests/query-labels.test.ts b/packages/ozone/tests/query-labels.test.ts
index e8f49a5e53c..999ecefce91 100644
--- a/packages/ozone/tests/query-labels.test.ts
+++ b/packages/ozone/tests/query-labels.test.ts
@@ -1,5 +1,5 @@
import AtpAgent from '@atproto/api'
-import { TestNetwork } from '@atproto/dev-env'
+import { EXAMPLE_LABELER, TestNetwork } from '@atproto/dev-env'
import { DisconnectError, Subscription } from '@atproto/xrpc-server'
import { ids, lexicons } from '../src/lexicon/lexicons'
import { Label } from '../src/lexicon/types/com/atproto/label/defs'
@@ -27,42 +27,42 @@ describe('ozone query labels', () => {
const toCreate = [
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'did:example:blah',
val: 'spam',
neg: false,
cts: new Date().toISOString(),
},
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'did:example:blah',
val: 'impersonation',
neg: false,
cts: new Date().toISOString(),
},
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'at://did:example:blah/app.bsky.feed.post/1234abcde',
val: 'spam',
neg: false,
cts: new Date().toISOString(),
},
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'at://did:example:blah/app.bsky.feed.post/1234abcfg',
val: 'spam',
neg: false,
cts: new Date().toISOString(),
},
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'at://did:example:blah/app.bsky.actor.profile/self',
val: 'spam',
neg: false,
cts: new Date().toISOString(),
},
{
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: 'did:example:thing',
val: 'spam',
neg: false,
diff --git a/packages/ozone/tests/sequencer.test.ts b/packages/ozone/tests/sequencer.test.ts
index cab809c34b5..712f2149103 100644
--- a/packages/ozone/tests/sequencer.test.ts
+++ b/packages/ozone/tests/sequencer.test.ts
@@ -1,4 +1,4 @@
-import { TestNetwork } from '@atproto/dev-env'
+import { EXAMPLE_LABELER, TestNetwork } from '@atproto/dev-env'
import { readFromGenerator, wait } from '@atproto/common'
import { LabelsEvt, Sequencer } from '../src/sequencer'
import Outbox from '../src/sequencer/outbox'
@@ -57,7 +57,7 @@ describe('sequencer', () => {
for (let i = 0; i < count; i++) {
const did = `did:example:${randomStr(10, 'base32')}`
const label = {
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
uri: did,
val: 'spam',
neg: false,
diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts
index 5a45c31b922..c1f266c1ecf 100644
--- a/packages/pds/src/lexicon/lexicons.ts
+++ b/packages/pds/src/lexicon/lexicons.ts
@@ -855,6 +855,17 @@ export const schemaDict = {
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
knownValues: ['content', 'media', 'none'],
},
+ defaultSetting: {
+ type: 'string',
+ description: 'The default setting for this label.',
+ knownValues: ['ignore', 'warn', 'hide'],
+ default: 'warn',
+ },
+ adultOnly: {
+ type: 'boolean',
+ description:
+ 'Does the user need to have adult content enabled in order to configure this label?',
+ },
locales: {
type: 'array',
items: {
@@ -3958,20 +3969,20 @@ export const schemaDict = {
},
},
},
- modsPref: {
+ labelersPref: {
type: 'object',
- required: ['mods'],
+ required: ['labelers'],
properties: {
- mods: {
+ labelers: {
type: 'array',
items: {
type: 'ref',
- ref: 'lex:app.bsky.actor.defs#modPrefItem',
+ ref: 'lex:app.bsky.actor.defs#labelerPrefItem',
},
},
},
},
- modPrefItem: {
+ labelerPrefItem: {
type: 'object',
required: ['did'],
properties: {
diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts
index bf2d045f093..7bd87c6e953 100644
--- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts
+++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts
@@ -338,36 +338,36 @@ export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
-export interface ModsPref {
- mods: ModPrefItem[]
+export interface LabelersPref {
+ labelers: LabelerPrefItem[]
[k: string]: unknown
}
-export function isModsPref(v: unknown): v is ModsPref {
+export function isLabelersPref(v: unknown): v is LabelersPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modsPref'
+ v.$type === 'app.bsky.actor.defs#labelersPref'
)
}
-export function validateModsPref(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modsPref', v)
+export function validateLabelersPref(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelersPref', v)
}
-export interface ModPrefItem {
+export interface LabelerPrefItem {
did: string
[k: string]: unknown
}
-export function isModPrefItem(v: unknown): v is ModPrefItem {
+export function isLabelerPrefItem(v: unknown): v is LabelerPrefItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
- v.$type === 'app.bsky.actor.defs#modPrefItem'
+ v.$type === 'app.bsky.actor.defs#labelerPrefItem'
)
}
-export function validateModPrefItem(v: unknown): ValidationResult {
- return lexicons.validate('app.bsky.actor.defs#modPrefItem', v)
+export function validateLabelerPrefItem(v: unknown): ValidationResult {
+ return lexicons.validate('app.bsky.actor.defs#labelerPrefItem', v)
}
diff --git a/packages/pds/src/lexicon/types/com/atproto/label/defs.ts b/packages/pds/src/lexicon/types/com/atproto/label/defs.ts
index 1af8b0f3890..d0225540a54 100644
--- a/packages/pds/src/lexicon/types/com/atproto/label/defs.ts
+++ b/packages/pds/src/lexicon/types/com/atproto/label/defs.ts
@@ -86,6 +86,10 @@ export interface LabelValueDefinition {
severity: 'inform' | 'alert' | 'none' | (string & {})
/** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */
blurs: 'content' | 'media' | 'none' | (string & {})
+ /** The default setting for this label. */
+ defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {})
+ /** Does the user need to have adult content enabled in order to configure this label? */
+ adultOnly?: boolean
locales: LabelValueDefinitionStrings[]
[k: string]: unknown
}
diff --git a/packages/pds/tests/seeds/basic.ts b/packages/pds/tests/seeds/basic.ts
index c0dbf009213..aa843fcbaaa 100644
--- a/packages/pds/tests/seeds/basic.ts
+++ b/packages/pds/tests/seeds/basic.ts
@@ -1,4 +1,4 @@
-import { SeedClient, TestBsky } from '@atproto/dev-env'
+import { EXAMPLE_LABELER, SeedClient, TestBsky } from '@atproto/dev-env'
import { ids } from '../../src/lexicon/lexicons'
import usersSeed from './users'
@@ -165,7 +165,7 @@ const createLabel = async (
val: opts.val,
cts: new Date().toISOString(),
neg: false,
- src: 'did:example:labeler',
+ src: EXAMPLE_LABELER,
})
.execute()
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8c64766285..f54d3f47947 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,5 +1,9 @@
lockfileVersion: '6.0'
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
importers:
.:
@@ -122,6 +126,9 @@ importers:
common-tags:
specifier: ^1.8.2
version: 1.8.2
+ get-port:
+ specifier: ^6.1.2
+ version: 6.1.2
packages/aws:
dependencies:
@@ -12059,7 +12066,3 @@ packages:
/zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false