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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDConfigurableFlagsOn Warn
!hide❌ (undefined)no-override, no-selfundefined
!no-promote❌ (undefined)no-selfundefined
!warn❌ (undefined)no-selfundefined
!no-unauthenticated❌ (undefined)no-override, unauthedundefined
dmca-violation❌ (undefined)no-override, no-selfundefined
doxxing❌ (undefined)no-override, no-selfundefined
pornadultundefined
sexualadultundefined
nudityadultundefined
goreadultundefined
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 - - - - - - - - - ${labelsRef()} -
IDConfigurableFlagsOn Warn
` -} - -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