From 23cec793d8518b5e8c859e9473dc0fae7a483fc6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 08:59:52 -0800 Subject: [PATCH 01/41] Rename bsky_labeler_did --- packages/api/src/agent.ts | 4 +- packages/api/src/bsky-agent.ts | 10 ++-- packages/api/src/const.ts | 2 +- packages/api/tests/bsky-agent.test.ts | 60 ++++++++++----------- packages/api/tests/moderation-prefs.test.ts | 28 +++++----- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index cfbd2d75c59..1bd37640bc4 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -18,7 +18,7 @@ import { AtpPersistSessionHandler, AtpAgentOpts, } from './types' -import { BSKY_MODSERVICE_DID } from './const' +import { BSKY_LABELER_DID } from './const' const REFRESH_SESSION = 'com.atproto.server.refreshSession' @@ -30,7 +30,7 @@ export class AtpAgent { service: URL api: AtpServiceClient session?: AtpSessionData - labelersHeader: string[] = [BSKY_MODSERVICE_DID] + labelersHeader: string[] = [BSKY_LABELER_DID] /** * The PDS URL, driven by the did doc. May be undefined. diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 57cc86cf8dd..5b5b9d4c257 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -13,7 +13,7 @@ import { BskyInterestsPreference, } from './types' import { LabelPreference } from './moderation/types' -import { BSKY_MODSERVICE_DID } from './const' +import { BSKY_LABELER_DID } from './const' import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' import { sanitizeMutedWordValue } from './util' @@ -421,11 +421,11 @@ export class BskyAgent extends AtpAgent { // ensure the bluesky moderation is configured const bskyModeration = prefs.moderationPrefs.mods.find( - (modPref) => modPref.did === BSKY_MODSERVICE_DID, + (modPref) => modPref.did === BSKY_LABELER_DID, ) if (!bskyModeration) { prefs.moderationPrefs.mods.unshift({ - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }) } @@ -893,8 +893,8 @@ function prefsArrayToLabelerDids( if (modsPref) { dids = (modsPref as AppBskyActorDefs.ModsPref).mods.map((mod) => mod.did) } - if (!dids.includes(BSKY_MODSERVICE_DID)) { - dids.unshift(BSKY_MODSERVICE_DID) + if (!dids.includes(BSKY_LABELER_DID)) { + dids.unshift(BSKY_LABELER_DID) } return dids } 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/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 197fc80e560..bc7aa54570b 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -3,7 +3,7 @@ import { BskyAgent, ComAtprotoRepoPutRecord, AppBskyActorProfile, - BSKY_MODSERVICE_DID, + BSKY_LABELER_DID, DEFAULT_LABEL_SETTINGS, } from '..' @@ -231,7 +231,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -265,7 +265,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -299,7 +299,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -333,7 +333,7 @@ describe('agent', () => { labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -371,7 +371,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -412,7 +412,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -453,7 +453,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -494,7 +494,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -535,7 +535,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -576,7 +576,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -623,7 +623,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -664,7 +664,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -705,7 +705,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -746,7 +746,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -787,7 +787,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -828,7 +828,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -876,7 +876,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -924,7 +924,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -972,7 +972,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -1041,7 +1041,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, }, ], }, @@ -1049,7 +1049,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, }, { did: 'did:plc:other', @@ -1135,7 +1135,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, { @@ -1179,7 +1179,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, { @@ -1223,7 +1223,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, { @@ -1267,7 +1267,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -1307,7 +1307,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -1347,7 +1347,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -1398,7 +1398,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -1440,7 +1440,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, }, ], }, diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index d97a21941eb..3129c25a626 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, BSKY_LABELER_DID, DEFAULT_LABEL_SETTINGS } from '..' import './util/moderation-behavior' describe('agent', () => { @@ -65,7 +65,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -86,7 +86,7 @@ describe('agent', () => { sort: 'oldest', }, }) - expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID]) + expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) }) it('adds/removes moderation services', async () => { @@ -100,7 +100,7 @@ describe('agent', () => { await agent.addModService('did:plc:other') expect(agent.labelersHeader).toStrictEqual([ - BSKY_MODSERVICE_DID, + BSKY_LABELER_DID, 'did:plc:other', ]) await expect(agent.getPreferences()).resolves.toStrictEqual({ @@ -112,7 +112,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, { @@ -138,12 +138,12 @@ describe('agent', () => { }, }) expect(agent.labelersHeader).toStrictEqual([ - BSKY_MODSERVICE_DID, + BSKY_LABELER_DID, 'did:plc:other', ]) await agent.removeModService('did:plc:other') - expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID]) + expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, hiddenPosts: [], @@ -153,7 +153,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -174,7 +174,7 @@ describe('agent', () => { prioritizeFollowedUsers: true, }, }) - expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID]) + expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) }) it('cant remove the default moderation service', async () => { @@ -186,8 +186,8 @@ describe('agent', () => { password: 'password', }) - await agent.removeModService(BSKY_MODSERVICE_DID) - expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID]) + await agent.removeModService(BSKY_LABELER_DID) + expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, hiddenPosts: [], @@ -197,7 +197,7 @@ describe('agent', () => { labels: DEFAULT_LABEL_SETTINGS, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, ], @@ -218,7 +218,7 @@ describe('agent', () => { prioritizeFollowedUsers: true, }, }) - expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID]) + expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) }) it('sets label preferences globally and per-moderator', async () => { @@ -244,7 +244,7 @@ describe('agent', () => { labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' }, mods: [ { - did: BSKY_MODSERVICE_DID, + did: BSKY_LABELER_DID, labels: {}, }, { From e6b1adba8a0256ae15ae4372b9b707e40f67aff9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 09:00:06 -0800 Subject: [PATCH 02/41] Use labeldef default setting --- packages/api/src/moderation/decision.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 58ce07615b8..0f7bce23846 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -224,7 +224,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 ( From 1101b8ad70402269dcd307e3d114dcb95975ae79 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 09:00:33 -0800 Subject: [PATCH 03/41] Add definedBy to interpretted label defs --- packages/api/src/moderation/types.ts | 2 +- packages/api/src/moderation/util.ts | 11 ++-- .../tests/moderation-custom-labels.test.ts | 16 +++-- packages/api/tests/moderation.test.ts | 64 +++++++++++-------- 4 files changed, 53 insertions(+), 40 deletions(-) diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index e43a8f8e6bf..02c673c9d43 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -60,7 +60,7 @@ export type LabelValueDefinitionFlag = export interface InterprettedLabelValueDefinition extends ComAtprotoLabelDefs.LabelValueDefinition { - // identifier: string + definedBy?: string | undefined // did of labeler or undefined for global configurable: boolean defaultSetting: LabelPreference // type narrowing flags: LabelValueDefinitionFlag[] diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts index e2a8f2251e9..f348cba1a20 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -18,6 +18,7 @@ export function isQuotedPostWithMedia( export function interpretLabelValueDefinition( def: ComAtprotoLabelDefs.LabelValueDefinition, + definedBy: string | undefined, ): InterprettedLabelValueDefinition { const behaviors: { account: ModerationBehavior @@ -76,6 +77,7 @@ export function interpretLabelValueDefinition( return { ...def, + definedBy, configurable: true, defaultSetting: 'warn', flags: ['no-self'], @@ -84,13 +86,14 @@ export function interpretLabelValueDefinition( } export function interpretLabelValueDefinitions( - modserviceView: AppBskyLabelerDefs.LabelerViewDetailed, + labelerView: AppBskyLabelerDefs.LabelerViewDetailed, ): InterprettedLabelValueDefinition[] { - return (modserviceView.policies?.labelValueDefinitions || []) + 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/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts index 2d177c3e3de..89dc135b39c 100644 --- a/packages/api/tests/moderation-custom-labels.test.ts +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -348,11 +348,13 @@ function makeCustomLabel( blurs: string, severity: string, ): InterprettedLabelValueDefinition { - return interpretLabelValueDefinition({ - identifier: 'custom', - blurs, - severity, - defaultSetting: 'warn', - locales: [], - }) + return interpretLabelValueDefinition( + { + identifier: 'custom', + blurs, + severity, + locales: [], + }, + 'did:web:labeler.test', + ) } diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index 4a010c41fff..b13d8c87ec8 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -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', + ), ], }, } @@ -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', + ), ], }, } @@ -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', + ), ], }, } From 55fa8045c7b76d9afe9288804fbcc831558003a5 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 09:00:44 -0800 Subject: [PATCH 04/41] Improve dev-env mocks for labels --- packages/dev-env/src/mock/index.ts | 138 ++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index ed222a55927..7929681f80b 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -349,29 +349,123 @@ 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 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`, }, - 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', + 'porn', + 'rude', + 'spam', + 'spider', + 'misinfo', + 'cool', + 'curate', + ], + labelValueDefinitions: [ + { + identifier: 'rude', + blurs: 'content', + severity: 'alert', + locales: [ + { + lang: 'en', + name: 'Rude', + description: 'Just such a jerk, you wouldnt believe it.', + }, + ], + }, + { + identifier: 'spam', + blurs: 'content', + severity: 'inform', + locales: [ + { + lang: 'en', + name: 'Spam', + description: + 'Low quality posts that dont add to the conversation.', + }, + ], + }, + { + identifier: 'spider', + blurs: 'media', + severity: 'alert', + locales: [ + { + lang: 'en', + name: 'Spider!', + description: 'Oh no its a spider.', + }, + ], + }, + { + identifier: 'cool', + blurs: 'none', + severity: 'inform', + locales: [ + { + lang: 'en', + name: 'Cool', + description: 'The coolest peeps in the atmosphere.', + }, + ], + }, + { + identifier: 'curate', + blurs: 'none', + severity: 'none', + 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: 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 { From dc4b91f133f61e3a4243297c9f6298064f18e51f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 14:35:11 -0800 Subject: [PATCH 05/41] Remove global label defs for dmca-violation, doxxing, and !no-promote --- packages/api/definitions/labels.json | 57 ------------------- packages/api/docs/labels.md | 18 ------ packages/api/src/moderation/const/labels.ts | 63 --------------------- 3 files changed, 138 deletions(-) diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json index c29c44d5d1b..f84e085adde 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, diff --git a/packages/api/docs/labels.md b/packages/api/docs/labels.md index 6d40b4f58ae..29894fcac49 100644 --- a/packages/api/docs/labels.md +++ b/packages/api/docs/labels.md @@ -51,12 +51,6 @@ The kind of UI behavior used when a warning must be applied. no-override, no-self undefined - - !no-promote - ❌ (undefined) - no-self - undefined - !warn ❌ (undefined) @@ -69,18 +63,6 @@ The kind of UI behavior used when a warning must be applied. no-override, unauthed undefined - - dmca-violation - ❌ (undefined) - no-override, no-self - undefined - - - doxxing - ❌ (undefined) - no-override, no-self - undefined - porn ✅ diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts index 624bb5fa395..66d16c347cb 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -3,11 +3,8 @@ import { InterprettedLabelValueDefinition, LabelPreference } from '../types' export type KnownLabelValue = | '!hide' - | '!no-promote' | '!warn' | '!no-unauthenticated' - | 'dmca-violation' - | 'doxxing' | 'porn' | 'sexual' | 'nudity' @@ -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, From 991dde08c7034adbf950b7feb14ad097a377297b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 14:36:36 -0800 Subject: [PATCH 06/41] Change nudity global label def to default to ignore and no longer be adult-only --- packages/api/definitions/labels.json | 4 ++-- packages/api/docs/labels.md | 2 +- packages/api/src/moderation/const/labels.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json index f84e085adde..1118b595386 100644 --- a/packages/api/definitions/labels.json +++ b/packages/api/definitions/labels.json @@ -127,8 +127,8 @@ { "identifier": "nudity", "configurable": true, - "defaultSetting": "warn", - "flags": ["adult"], + "defaultSetting": "ignore", + "flags": [], "severity": "none", "blurs": "media", "behaviors": { diff --git a/packages/api/docs/labels.md b/packages/api/docs/labels.md index 29894fcac49..a26bec2c9d3 100644 --- a/packages/api/docs/labels.md +++ b/packages/api/docs/labels.md @@ -78,7 +78,7 @@ The kind of UI behavior used when a warning must be applied. nudity ✅ - adult + undefined diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts index 66d16c347cb..9d99718a144 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -13,7 +13,7 @@ export type KnownLabelValue = export const DEFAULT_LABEL_SETTINGS: Record = { porn: 'hide', sexual: 'warn', - nudity: 'warn', + nudity: 'ignore', gore: 'warn', } @@ -152,8 +152,8 @@ export const LABELS: Record = nudity: { identifier: 'nudity', configurable: true, - defaultSetting: 'warn', - flags: ['adult'], + defaultSetting: 'ignore', + flags: [], severity: 'none', blurs: 'media', behaviors: { From e223abc306122d4294caa4feab7c95389b209309 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 14:38:15 -0800 Subject: [PATCH 07/41] Remove old !no-promote tests --- .../api/tests/moderation-behaviors.test.ts | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/packages/api/tests/moderation-behaviors.test.ts b/packages/api/tests/moderation-behaviors.test.ts index 4f782dd0155..68ae09983b8 100644 --- a/packages/api/tests/moderation-behaviors.test.ts +++ b/packages/api/tests/moderation-behaviors.test.ts @@ -172,55 +172,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', From f7ed46f31635a5e8ed9b2b21c11631038c916711 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 15:12:40 -0800 Subject: [PATCH 08/41] Add mod authorities header and move bsky labeler into it --- packages/api/src/agent.ts | 27 +++- packages/api/src/bsky-agent.ts | 15 -- packages/api/src/types.ts | 3 +- packages/api/tests/agent.test.ts | 24 +++ packages/api/tests/bsky-agent.test.ts | 154 ++++---------------- packages/api/tests/moderation-prefs.test.ts | 83 +---------- 6 files changed, 86 insertions(+), 220 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 1bd37640bc4..732d82715e6 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -20,6 +20,8 @@ import { } from './types' import { BSKY_LABELER_DID } from './const' +const MAX_MOD_AUTHORITIES = 3 +const MAX_LABELERS = 10 const REFRESH_SESSION = 'com.atproto.server.refreshSession' /** @@ -30,7 +32,7 @@ export class AtpAgent { service: URL api: AtpServiceClient session?: AtpSessionData - labelersHeader: string[] = [BSKY_LABELER_DID] + labelersHeader: string[] = [] /** * The PDS URL, driven by the did doc. May be undefined. @@ -50,11 +52,21 @@ export class AtpAgent { */ static fetch: AtpAgentFetchHandler | undefined = defaultFetchHandler + /** + * The moderation authorities to be used across all requests + */ + static modAuthoritiesHeader: 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.modAuthorities) { + AtpAgent.modAuthoritiesHeader = opts.modAuthorities + } } constructor(opts: AtpAgentOpts) { @@ -212,12 +224,21 @@ export class AtpAgent { authorization: `Bearer ${this.session.accessJwt}`, } } + if (AtpAgent.modAuthoritiesHeader.length) { + reqHeaders = { + ...reqHeaders, + 'atproto-mod-authorities': AtpAgent.modAuthoritiesHeader + .filter((str) => str.startsWith('did:')) + .slice(0, MAX_MOD_AUTHORITIES) + .join(','), + } + } if (this.labelersHeader.length) { reqHeaders = { ...reqHeaders, 'atproto-labelers': this.labelersHeader .filter((str) => str.startsWith('did:')) - .slice(0, 10) + .slice(0, MAX_LABELERS) .join(','), } } diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 5b5b9d4c257..38c18bcec45 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -13,7 +13,6 @@ import { BskyInterestsPreference, } from './types' import { LabelPreference } from './moderation/types' -import { BSKY_LABELER_DID } from './const' import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' import { sanitizeMutedWordValue } from './util' @@ -419,17 +418,6 @@ export class BskyAgent extends AtpAgent { } } - // ensure the bluesky moderation is configured - const bskyModeration = prefs.moderationPrefs.mods.find( - (modPref) => modPref.did === BSKY_LABELER_DID, - ) - if (!bskyModeration) { - prefs.moderationPrefs.mods.unshift({ - did: BSKY_LABELER_DID, - labels: {}, - }) - } - // apply the label prefs for (const pref of labelPrefs) { if (pref.labelerDid) { @@ -893,9 +881,6 @@ function prefsArrayToLabelerDids( if (modsPref) { dids = (modsPref as AppBskyActorDefs.ModsPref).mods.map((mod) => mod.did) } - if (!dids.includes(BSKY_LABELER_DID)) { - dids.unshift(BSKY_LABELER_DID) - } return dids } diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index dac0666a41a..cd513271745 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -67,7 +67,8 @@ export type AtpAgentFetchHandler = ( * AtpAgent global config opts */ export interface AtpAgentGlobalOpts { - fetch: AtpAgentFetchHandler + fetch?: AtpAgentFetchHandler + modAuthorities?: string[] } /** diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index 89491cc1616..e4ee8dd251f 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -5,6 +5,7 @@ import { AtpAgentFetchHandlerResponse, AtpSessionEvent, AtpSessionData, + BSKY_LABELER_DID, } from '..' import { TestNetworkNoAppView } from '@atproto/dev-env' import { getPdsEndpoint, isValidDidDoc } from '@atproto/common-web' @@ -481,6 +482,29 @@ describe('agent', () => { }) }) + describe('Mod authorities header', () => { + it('adds the authorities header as expected', async () => { + const server = await createHeaderEchoServer(15991) + const agent = new AtpAgent({ service: 'http://localhost:15991' }) + const agent2 = new AtpAgent({ service: 'http://localhost:15991' }) + + const res1 = await agent.com.atproto.server.describeServer() + expect(res1.data['atproto-mod-authorities']).toEqual(BSKY_LABELER_DID) + + AtpAgent.configure({ modAuthorities: ['did:plc:test1', 'did:plc:test2'] }) + const res2 = await agent.com.atproto.server.describeServer() + expect(res2.data['atproto-mod-authorities']).toEqual( + 'did:plc:test1,did:plc:test2', + ) + const res3 = await agent2.com.atproto.server.describeServer() + expect(res3.data['atproto-mod-authorities']).toEqual( + 'did:plc:test1,did:plc:test2', + ) + + await new Promise((r) => server.close(r)) + }) + }) + describe('configureLabelersHeader', () => { it('adds the labelers header as expected', async () => { const server = await createHeaderEchoServer(15991) diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index bc7aa54570b..bb914b0ebcc 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_LABELER_DID, DEFAULT_LABEL_SETTINGS, } from '..' @@ -229,12 +228,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -263,12 +257,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: true, labels: DEFAULT_LABEL_SETTINGS, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -297,12 +286,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -331,12 +315,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -369,12 +348,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -410,12 +384,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -451,12 +420,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -492,12 +456,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -533,12 +492,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -574,12 +528,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -621,12 +570,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -662,12 +606,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -703,12 +642,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -744,12 +678,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -785,12 +714,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -826,12 +750,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -874,12 +793,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -922,12 +836,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -970,12 +879,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1041,7 +945,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', }, ], }, @@ -1049,7 +953,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', }, { did: 'did:plc:other', @@ -1135,7 +1039,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, { @@ -1179,7 +1083,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, { @@ -1223,7 +1127,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, { @@ -1267,7 +1171,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, ], @@ -1307,7 +1211,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, ], @@ -1347,7 +1251,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, ], @@ -1398,7 +1302,7 @@ describe('agent', () => { }, mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', labels: {}, }, ], @@ -1440,7 +1344,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#modsPref', mods: [ { - did: BSKY_LABELER_DID, + did: 'did:plc:first-labeler', }, ], }, diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index 3129c25a626..03678842c16 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_LABELER_DID, DEFAULT_LABEL_SETTINGS } from '..' +import { BskyAgent, DEFAULT_LABEL_SETTINGS } from '..' import './util/moderation-behavior' describe('agent', () => { @@ -63,12 +63,7 @@ describe('agent', () => { sexual: 'ignore', gore: 'ignore', }, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -86,7 +81,6 @@ describe('agent', () => { sort: 'oldest', }, }) - expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) }) it('adds/removes moderation services', async () => { @@ -99,10 +93,7 @@ describe('agent', () => { }) await agent.addModService('did:plc:other') - expect(agent.labelersHeader).toStrictEqual([ - BSKY_LABELER_DID, - 'did:plc:other', - ]) + expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, hiddenPosts: [], @@ -111,10 +102,6 @@ describe('agent', () => { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, { did: 'did:plc:other', labels: {}, @@ -137,13 +124,10 @@ describe('agent', () => { prioritizeFollowedUsers: true, }, }) - expect(agent.labelersHeader).toStrictEqual([ - BSKY_LABELER_DID, - 'did:plc:other', - ]) + expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) await agent.removeModService('did:plc:other') - expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_DID]) + expect(agent.labelersHeader).toStrictEqual([]) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, hiddenPosts: [], @@ -151,12 +135,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, - ], + mods: [], }, birthDate: undefined, feedViewPrefs: { @@ -174,51 +153,7 @@ describe('agent', () => { prioritizeFollowedUsers: true, }, }) - expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_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', - }) - - await agent.removeModService(BSKY_LABELER_DID) - expect(agent.labelersHeader).toStrictEqual([BSKY_LABELER_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_LABELER_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_LABELER_DID]) + expect(agent.labelersHeader).toStrictEqual([]) }) it('sets label preferences globally and per-moderator', async () => { @@ -243,10 +178,6 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' }, mods: [ - { - did: BSKY_LABELER_DID, - labels: {}, - }, { did: 'did:plc:other', labels: { From a5b4160d4662720b347b826b37a8580ae94e9172 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 15:27:45 -0800 Subject: [PATCH 09/41] Rename modsPref and addModService/removeModService to labelersPref and add/removeLabeler --- lexicons/app/bsky/actor/defs.json | 10 +-- packages/api/src/bsky-agent.ts | 90 ++++++++++--------- packages/api/src/client/lexicons.ts | 10 +-- .../src/client/types/app/bsky/actor/defs.ts | 22 ++--- packages/api/src/moderation/decision.ts | 2 +- packages/api/src/moderation/types.ts | 4 +- packages/api/tests/bsky-agent.test.ts | 66 +++++++------- .../tests/moderation-custom-labels.test.ts | 2 +- packages/api/tests/moderation-prefs.test.ts | 14 +-- packages/api/tests/moderation.test.ts | 18 ++-- .../api/tests/util/moderation-behavior.ts | 2 +- packages/bsky/src/lexicon/lexicons.ts | 10 +-- .../src/lexicon/types/app/bsky/actor/defs.ts | 22 ++--- packages/ozone/src/lexicon/lexicons.ts | 10 +-- .../src/lexicon/types/app/bsky/actor/defs.ts | 22 ++--- packages/pds/src/lexicon/lexicons.ts | 10 +-- .../src/lexicon/types/app/bsky/actor/defs.ts | 22 ++--- 17 files changed, 171 insertions(+), 165 deletions(-) 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/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 38c18bcec45..4dfd64efc75 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -329,7 +329,7 @@ export class BskyAgent extends AtpAgent { moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS }, - mods: [], + labelers: [], }, birthDate: undefined, interests: { @@ -355,12 +355,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 ( @@ -421,11 +421,11 @@ export class BskyAgent extends AtpAgent { // 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 @@ -530,60 +530,64 @@ export class BskyAgent extends AtpAgent { }) } - 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 @@ -872,14 +876,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 (labelersPref) { + dids = (labelersPref as AppBskyActorDefs.LabelersPref).labelers.map( + (labeler) => labeler.did, + ) } return dids } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 5a45c31b922..4dd5451106d 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -3958,20 +3958,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/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 0f7bce23846..48ecf1fd8a7 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -214,7 +214,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 diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index 02c673c9d43..8edaffd3546 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -127,7 +127,7 @@ export type ModerationCause = | { type: 'muted'; source: ModerationCauseSource; priority: 6 } | { type: 'hidden'; source: ModerationCauseSource; priority: 6 } -export interface ModerationPrefsModerator { +export interface ModerationPrefsLabeler { did: string labels: Record } @@ -135,7 +135,7 @@ export interface ModerationPrefsModerator { export interface ModerationPrefs { adultContentEnabled: boolean labels: Record - mods: ModerationPrefsModerator[] + labelers: ModerationPrefsLabeler[] } export interface ModerationOpts { diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index bb914b0ebcc..dca946ff94e 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -228,7 +228,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -257,7 +257,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: true, labels: DEFAULT_LABEL_SETTINGS, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -286,7 +286,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -315,7 +315,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -348,7 +348,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -384,7 +384,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -420,7 +420,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -456,7 +456,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -492,7 +492,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -528,7 +528,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -570,7 +570,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -606,7 +606,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -642,7 +642,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -678,7 +678,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -714,7 +714,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -750,7 +750,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -793,7 +793,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -836,7 +836,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -879,7 +879,7 @@ describe('agent', () => { misinfo: 'hide', spam: 'ignore', }, - mods: [], + labelers: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -942,16 +942,16 @@ describe('agent', () => { visibility: 'warn', }, { - $type: 'app.bsky.actor.defs#modsPref', - mods: [ + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [ { did: 'did:plc:first-labeler', }, ], }, { - $type: 'app.bsky.actor.defs#modsPref', - mods: [ + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [ { did: 'did:plc:first-labeler', }, @@ -1037,7 +1037,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'warn', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1081,7 +1081,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'warn', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1125,7 +1125,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1157,7 +1157,7 @@ describe('agent', () => { hiddenPosts: [], }) - await agent.removeModService('did:plc:other') + await agent.removeLabeler('did:plc:other') await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: [], @@ -1169,7 +1169,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1209,7 +1209,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1249,7 +1249,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1300,7 +1300,7 @@ describe('agent', () => { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', }, - mods: [ + labelers: [ { did: 'did:plc:first-labeler', labels: {}, @@ -1341,8 +1341,8 @@ describe('agent', () => { visibility: 'ignore', }, { - $type: 'app.bsky.actor.defs#modsPref', - mods: [ + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [ { did: 'did:plc:first-labeler', }, diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts index 89dc135b39c..fcc7d50b8b5 100644 --- a/packages/api/tests/moderation-custom-labels.test.ts +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -331,7 +331,7 @@ function modOpts(blurs: string, severity: string): ModerationOpts { prefs: { adultContentEnabled: true, labels: {}, - mods: [ + labelers: [ { did: 'did:web:labeler.test', labels: { custom: 'hide' }, diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index 03678842c16..00d8f2c24d0 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -63,7 +63,7 @@ describe('agent', () => { sexual: 'ignore', gore: 'ignore', }, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -92,7 +92,7 @@ describe('agent', () => { password: 'password', }) - await agent.addModService('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 }, @@ -101,7 +101,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [ + labelers: [ { did: 'did:plc:other', labels: {}, @@ -126,7 +126,7 @@ describe('agent', () => { }) expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) - await agent.removeModService('did:plc:other') + await agent.removeLabeler('did:plc:other') expect(agent.labelersHeader).toStrictEqual([]) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, @@ -135,7 +135,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, - mods: [], + labelers: [], }, birthDate: undefined, feedViewPrefs: { @@ -165,7 +165,7 @@ 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') @@ -177,7 +177,7 @@ describe('agent', () => { moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' }, - mods: [ + labelers: [ { did: 'did:plc:other', labels: { diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index b13d8c87ec8..8d025557c3a 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' }, @@ -311,7 +311,7 @@ describe('Moderation', () => { prefs: { adultContentEnabled: true, labels: {}, - mods: [ + labelers: [ { did: 'did:web:labeler.test', labels: {}, @@ -374,7 +374,7 @@ describe('Moderation', () => { prefs: { adultContentEnabled: true, labels: {}, - mods: [ + labelers: [ { did: 'did:web:labeler.test', labels: { BadLabel: 'hide', 'bad/label': 'hide' }, diff --git a/packages/api/tests/util/moderation-behavior.ts b/packages/api/tests/util/moderation-behavior.ts index 07c8310a4d2..d7347c66254 100644 --- a/packages/api/tests/util/moderation-behavior.ts +++ b/packages/api/tests/util/moderation-behavior.ts @@ -254,7 +254,7 @@ export class ModerationBehaviorSuiteRunner { this.configurations[scenario.cfg]?.adultContentEnabled, ), labels: this.configurations[scenario.cfg].settings || {}, - mods: [ + labelers: [ { did: 'did:plc:fake-labeler', labels: {}, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 810d5ea03a2..364e3fb063c 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -3958,20 +3958,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/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 5a45c31b922..4dd5451106d 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -3958,20 +3958,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/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 5a45c31b922..4dd5451106d 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -3958,20 +3958,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) } From 1241130d52c864e58f0633128ca397e1c947ccbe Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 15:58:43 -0800 Subject: [PATCH 10/41] Add defaultSetting and adultOnly to custom label value definitions --- lexicons/com/atproto/label/defs.json | 10 + packages/api/src/client/lexicons.ts | 11 + .../client/types/com/atproto/label/defs.ts | 4 + packages/api/src/moderation/util.ts | 25 ++- packages/api/tests/moderation.test.ts | 209 ++++++++++++++++++ packages/bsky/src/lexicon/lexicons.ts | 11 + .../lexicon/types/com/atproto/label/defs.ts | 4 + packages/ozone/src/lexicon/lexicons.ts | 11 + .../lexicon/types/com/atproto/label/defs.ts | 4 + packages/pds/src/lexicon/lexicons.ts | 11 + .../lexicon/types/com/atproto/label/defs.ts | 4 + 11 files changed, 299 insertions(+), 5 deletions(-) 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/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 4dd5451106d..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: { 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/moderation/util.ts b/packages/api/src/moderation/util.ts index f348cba1a20..50a9b64d82c 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 { + InterprettedLabelValueDefinition, + ModerationBehavior, + LabelPreference, + LabelValueDefinitionFlag, +} from './types' export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View { return Boolean(embed && AppBskyEmbedRecord.isView(embed)) @@ -40,7 +45,7 @@ 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' @@ -48,7 +53,7 @@ export function interpretLabelValueDefinition( behaviors.profile.displayName = 'blur' // 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 @@ -75,12 +80,22 @@ export function interpretLabelValueDefinition( 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, } } diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index 8d025557c3a..6d4cc998ed0 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -441,4 +441,213 @@ 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(['filter']) + 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(['filter']) + 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([]) + }) }) diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 364e3fb063c..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: { 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/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 4dd5451106d..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: { 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/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 4dd5451106d..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: { 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 } From 426bb0365f558fa1949a5bc7117118a9b5247217 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 16:00:14 -0800 Subject: [PATCH 11/41] Rename InterprettedLabelValueDefinition to InterpretedLabelValueDefinition --- packages/api/src/moderation/const/labels.ts | 4 ++-- packages/api/src/moderation/decision.ts | 2 +- packages/api/src/moderation/types.ts | 8 ++++---- packages/api/src/moderation/util.ts | 6 +++--- packages/api/tests/moderation-custom-labels.test.ts | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts index 9d99718a144..9dbd9219abd 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -1,5 +1,5 @@ /** this doc is generated by ./scripts/code/labels.mjs **/ -import { InterprettedLabelValueDefinition, LabelPreference } from '../types' +import { InterpretedLabelValueDefinition, LabelPreference } from '../types' export type KnownLabelValue = | '!hide' @@ -17,7 +17,7 @@ export const DEFAULT_LABEL_SETTINGS: Record = { gore: 'warn', } -export const LABELS: Record = +export const LABELS: Record = { '!hide': { identifier: '!hide', diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 48ecf1fd8a7..d2dbc874fad 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -8,7 +8,7 @@ import { LabelPreference, ModerationCause, ModerationOpts, - InterprettedLabelValueDefinition, + InterpretedLabelValueDefinition, LabelTarget, ModerationBehavior, CUSTOM_LABEL_VALUE_RE, diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index 8edaffd3546..1e027bf428e 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -58,7 +58,7 @@ export type LabelValueDefinitionFlag = | 'unauthed' | 'no-self' -export interface InterprettedLabelValueDefinition +export interface InterpretedLabelValueDefinition extends ComAtprotoLabelDefs.LabelValueDefinition { definedBy?: string | undefined // did of labeler or undefined for global configurable: boolean @@ -73,7 +73,7 @@ export interface InterprettedLabelValueDefinition export type LabelDefinitionMap = Record< KnownLabelValue, - InterprettedLabelValueDefinition + InterpretedLabelValueDefinition > // subjects @@ -118,7 +118,7 @@ export type ModerationCause = type: 'label' source: ModerationCauseSource label: Label - labelDef: InterprettedLabelValueDefinition + labelDef: InterpretedLabelValueDefinition setting: LabelPreference behavior: ModerationBehavior noOverride: boolean @@ -144,5 +144,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 50a9b64d82c..afb0bc474e0 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -5,7 +5,7 @@ import { ComAtprotoLabelDefs, } from '../client' import { - InterprettedLabelValueDefinition, + InterpretedLabelValueDefinition, ModerationBehavior, LabelPreference, LabelValueDefinitionFlag, @@ -24,7 +24,7 @@ export function isQuotedPostWithMedia( export function interpretLabelValueDefinition( def: ComAtprotoLabelDefs.LabelValueDefinition, definedBy: string | undefined, -): InterprettedLabelValueDefinition { +): InterpretedLabelValueDefinition { const behaviors: { account: ModerationBehavior profile: ModerationBehavior @@ -102,7 +102,7 @@ export function interpretLabelValueDefinition( export function interpretLabelValueDefinitions( labelerView: AppBskyLabelerDefs.LabelerViewDetailed, -): InterprettedLabelValueDefinition[] { +): InterpretedLabelValueDefinition[] { return (labelerView.policies?.labelValueDefinitions || []) .filter( (labelValDef) => diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts index fcc7d50b8b5..f9054e246ce 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' @@ -347,7 +347,7 @@ function modOpts(blurs: string, severity: string): ModerationOpts { function makeCustomLabel( blurs: string, severity: string, -): InterprettedLabelValueDefinition { +): InterpretedLabelValueDefinition { return interpretLabelValueDefinition( { identifier: 'custom', From b8f0bf0ac32c8b26ce4c1f266eaad932e109b575 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 16:15:38 -0800 Subject: [PATCH 12/41] Update dev-env mock --- packages/dev-env/src/mock/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 7929681f80b..24347cfef59 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -385,6 +385,8 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'rude', blurs: 'content', severity: 'alert', + defaultSetting: 'warn', + adultOnly: true, locales: [ { lang: 'en', @@ -397,6 +399,7 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'spam', blurs: 'content', severity: 'inform', + defaultSetting: 'hide', locales: [ { lang: 'en', @@ -410,6 +413,7 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'spider', blurs: 'media', severity: 'alert', + defaultSetting: 'warn', locales: [ { lang: 'en', @@ -422,6 +426,7 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'cool', blurs: 'none', severity: 'inform', + defaultSetting: 'warn', locales: [ { lang: 'en', @@ -434,6 +439,7 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'curate', blurs: 'none', severity: 'none', + defaultSetting: 'warn', locales: [ { lang: 'en', From a953c1edfce6e953d0ab4f9ffbad3f3d06778c33 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 8 Mar 2024 18:13:46 -0800 Subject: [PATCH 13/41] Move muted words and hidden posts into moderationPrefs --- packages/api/src/bsky-agent.ts | 8 +- packages/api/src/moderation/decision.ts | 1 - packages/api/src/moderation/types.ts | 2 + packages/api/src/types.ts | 2 - packages/api/tests/bsky-agent.test.ts | 156 ++++++++++---------- packages/api/tests/moderation-prefs.test.ts | 16 +- 6 files changed, 92 insertions(+), 93 deletions(-) diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 4dfd64efc75..64fb4cd1c89 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -330,13 +330,13 @@ export class BskyAgent extends AtpAgent { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], } const res = await this.app.bsky.actor.getPreferences({}) const labelPrefs: AppBskyActorDefs.ContentLabelPref[] = [] @@ -407,14 +407,14 @@ 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 } } diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index d2dbc874fad..5eef1694d0f 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -8,7 +8,6 @@ import { LabelPreference, ModerationCause, ModerationOpts, - InterpretedLabelValueDefinition, LabelTarget, ModerationBehavior, CUSTOM_LABEL_VALUE_RE, diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index 1e027bf428e..244544284bf 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -136,6 +136,8 @@ export interface ModerationPrefs { adultContentEnabled: boolean labels: Record labelers: ModerationPrefsLabeler[] + mutedWords: AppBskyActorDefs.MutedWord[] + hiddenPosts: string[] } export interface ModerationOpts { diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index cd513271745..3834d1746c9 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -114,6 +114,4 @@ export interface BskyPreferences { moderationPrefs: ModerationPrefs birthDate: Date | undefined interests: BskyInterestsPreference - mutedWords: AppBskyActorDefs.MutedWord[] - hiddenPosts: string[] } diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index dca946ff94e..c28fb1b4c05 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -229,6 +229,8 @@ describe('agent', () => { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -247,8 +249,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setAdultContentEnabled(true) @@ -258,6 +258,8 @@ describe('agent', () => { adultContentEnabled: true, labels: DEFAULT_LABEL_SETTINGS, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -276,8 +278,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setAdultContentEnabled(false) @@ -287,6 +287,8 @@ describe('agent', () => { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -305,8 +307,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setContentLabelPref('misinfo', 'hide') @@ -316,6 +316,8 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -334,8 +336,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setContentLabelPref('spam', 'ignore') @@ -349,6 +349,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -367,8 +369,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.addSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -385,6 +385,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -403,8 +405,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -421,6 +421,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -439,8 +441,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.removePinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -457,6 +457,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -475,8 +477,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -493,6 +493,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -511,8 +513,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -529,6 +529,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -547,8 +549,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake2') @@ -571,6 +571,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -589,8 +591,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -607,6 +607,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { @@ -625,8 +627,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) @@ -643,6 +643,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -661,8 +663,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setFeedViewPrefs('home', { hideReplies: true }) @@ -679,6 +679,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -697,8 +699,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setFeedViewPrefs('home', { hideReplies: false }) @@ -715,6 +715,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -733,8 +735,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setFeedViewPrefs('other', { hideReplies: true }) @@ -751,6 +751,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -776,8 +778,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setThreadViewPrefs({ sort: 'random' }) @@ -794,6 +794,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -819,8 +821,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setThreadViewPrefs({ sort: 'oldest' }) @@ -837,6 +837,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -862,8 +864,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setInterestsPref({ tags: ['foo', 'bar'] }) @@ -880,6 +880,8 @@ describe('agent', () => { spam: 'ignore', }, labelers: [], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -905,8 +907,6 @@ describe('agent', () => { interests: { tags: ['foo', 'bar'], }, - mutedWords: [], - hiddenPosts: [], }) }) @@ -1047,6 +1047,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1065,8 +1067,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setAdultContentEnabled(false) @@ -1091,6 +1091,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1109,8 +1111,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setContentLabelPref('porn', 'ignore') @@ -1135,6 +1135,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1153,8 +1155,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.removeLabeler('did:plc:other') @@ -1175,6 +1175,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1193,8 +1195,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') @@ -1215,6 +1215,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1233,8 +1235,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) @@ -1255,6 +1255,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1273,8 +1275,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) await agent.setFeedViewPrefs('home', { @@ -1306,6 +1306,8 @@ describe('agent', () => { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { @@ -1324,8 +1326,6 @@ describe('agent', () => { interests: { tags: [], }, - mutedWords: [], - hiddenPosts: [], }) const res = await agent.app.bsky.actor.getPreferences() @@ -1400,7 +1400,7 @@ describe('agent', () => { await agent.upsertMutedWords(mutedWords) await agent.upsertMutedWords(mutedWords) // double await expect(agent.getPreferences()).resolves.toHaveProperty( - 'mutedWords', + 'moderationPrefs.mutedWords', mutedWords, ) }) @@ -1412,7 +1412,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 @@ -1435,7 +1435,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'), @@ -1460,7 +1460,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'], @@ -1471,7 +1471,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'), @@ -1482,17 +1482,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) @@ -1500,65 +1500,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) }) @@ -1567,7 +1567,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'), @@ -1578,7 +1578,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'), @@ -1587,14 +1587,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() }) @@ -1618,7 +1618,7 @@ describe('agent', () => { await agent.hidePost(postUri) await agent.hidePost(postUri) // double, should dedupe await expect(agent.getPreferences()).resolves.toHaveProperty( - 'hiddenPosts', + 'moderationPrefs.hiddenPosts', [postUri], ) }) @@ -1626,13 +1626,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-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index 00d8f2c24d0..93255941fce 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -53,7 +53,6 @@ describe('agent', () => { pinned: undefined, saved: undefined, }, - hiddenPosts: [], interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -64,6 +63,8 @@ describe('agent', () => { gore: 'ignore', }, labelers: [], + hiddenPosts: [], + mutedWords: [], }, birthDate: undefined, feedViewPrefs: { @@ -75,7 +76,6 @@ describe('agent', () => { hideReposts: false, }, }, - mutedWords: [], threadViewPrefs: { prioritizeFollowedUsers: true, sort: 'oldest', @@ -96,7 +96,6 @@ describe('agent', () => { expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, - hiddenPosts: [], interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -107,6 +106,8 @@ describe('agent', () => { labels: {}, }, ], + hiddenPosts: [], + mutedWords: [], }, birthDate: undefined, feedViewPrefs: { @@ -118,7 +119,6 @@ describe('agent', () => { hideQuotePosts: false, }, }, - mutedWords: [], threadViewPrefs: { sort: 'oldest', prioritizeFollowedUsers: true, @@ -130,12 +130,13 @@ describe('agent', () => { 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, labelers: [], + hiddenPosts: [], + mutedWords: [], }, birthDate: undefined, feedViewPrefs: { @@ -147,7 +148,6 @@ describe('agent', () => { hideQuotePosts: false, }, }, - mutedWords: [], threadViewPrefs: { sort: 'oldest', prioritizeFollowedUsers: true, @@ -172,7 +172,6 @@ describe('agent', () => { await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, - hiddenPosts: [], interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -186,6 +185,8 @@ describe('agent', () => { }, }, ], + hiddenPosts: [], + mutedWords: [], }, birthDate: undefined, feedViewPrefs: { @@ -197,7 +198,6 @@ describe('agent', () => { hideQuotePosts: false, }, }, - mutedWords: [], threadViewPrefs: { sort: 'oldest', prioritizeFollowedUsers: true, From 1f51a489a1542a26e6ae28a8c44ff24541a37a55 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 8 Mar 2024 18:13:59 -0800 Subject: [PATCH 14/41] Add muted word and hidden post handling to moderatePost --- packages/api/src/moderation/decision.ts | 10 + packages/api/src/moderation/index.ts | 1 + packages/api/src/moderation/mutewords.ts | 123 +++++++++++ packages/api/src/moderation/subjects/post.ts | 219 +++++++++++++++++++ packages/api/src/moderation/types.ts | 1 + 5 files changed, 354 insertions(+) create mode 100644 packages/api/src/moderation/mutewords.ts diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 5eef1694d0f..0a072bd516e 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -155,6 +155,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({ diff --git a/packages/api/src/moderation/index.ts b/packages/api/src/moderation/index.ts index 2b7a1e9164c..86d8ee67988 100644 --- a/packages/api/src/moderation/index.ts +++ b/packages/api/src/moderation/index.ts @@ -17,6 +17,7 @@ import { ModerationDecision } from './decision' export { ModerationUI } from './ui' export { ModerationDecision } from './decision' +export { hasMutedWord } from './mutewords' export { interpretLabelValueDefinition, interpretLabelValueDefinitions, diff --git a/packages/api/src/moderation/mutewords.ts b/packages/api/src/moderation/mutewords.ts new file mode 100644 index 00000000000..de8ad9cb163 --- /dev/null +++ b/packages/api/src/moderation/mutewords.ts @@ -0,0 +1,123 @@ +import { AppBskyActorDefs, AppBskyRichtextFacet } from '../client' + +const REGEX = { + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, + 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/post.ts b/packages/api/src/moderation/subjects/post.ts index f93df9b92d9..6cfaff0d894 100644 --- a/packages/api/src/moderation/subjects/post.ts +++ b/packages/api/src/moderation/subjects/post.ts @@ -1,5 +1,14 @@ import { ModerationDecision } from '../decision' +import { + AppBskyFeedPost, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedExternal, + AppBskyActorDefs, +} from '../../client' import { ModerationSubjectPost, ModerationOpts } from '../types' +import { hasMutedWord } from '../mutewords' export function decidePost( subject: ModerationSubjectPost, @@ -14,6 +23,216 @@ 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)) + } 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/types.ts b/packages/api/src/moderation/types.ts index 244544284bf..6f662049283 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -125,6 +125,7 @@ export type ModerationCause = priority: 1 | 2 | 5 | 7 | 8 } | { type: 'muted'; source: ModerationCauseSource; priority: 6 } + | { type: 'mute-word'; source: ModerationCauseSource; priority: 6 } | { type: 'hidden'; source: ModerationCauseSource; priority: 6 } export interface ModerationPrefsLabeler { From 9ef28ccf08629e24e770c4843660cecfd22a9b9f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sat, 9 Mar 2024 10:46:16 -0600 Subject: [PATCH 15/41] Add mutewords tests --- .../api/tests/moderation-mutewords.test.ts | 652 ++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 packages/api/tests/moderation-mutewords.test.ts diff --git a/packages/api/tests/moderation-mutewords.test.ts b/packages/api/tests/moderation-mutewords.test.ts new file mode 100644 index 00000000000..de69f679413 --- /dev/null +++ b/packages/api/tests/moderation-mutewords.test.ts @@ -0,0 +1,652 @@ +import { RichText } 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) + }) + }) + }) + + // TODO + describe.skip(`doesn't mute own post`, () => { + it(`does mute if it isn't own post`, () => { + const rt = new RichText({ + text: `Mute words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{ value: 'words', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`doesn't mute own post when muted word is in text`, () => { + const rt = new RichText({ + text: `Mute words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{ value: 'words', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + + it(`doesn't mute own post when muted word is in tags`, () => { + const rt = new RichText({ + text: `Mute #words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{ value: 'words', targets: ['tags'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + }) +}) From 2c6265262f9252f00944759f6c894a6e91f250ec Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 10:04:43 -0800 Subject: [PATCH 16/41] Finish muteword tests --- packages/api/src/mocker.ts | 4 + .../api/tests/moderation-mutewords.test.ts | 111 ++++++++++++------ 2 files changed, 79 insertions(+), 36 deletions(-) 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/tests/moderation-mutewords.test.ts b/packages/api/tests/moderation-mutewords.test.ts index de69f679413..18a2f556887 100644 --- a/packages/api/tests/moderation-mutewords.test.ts +++ b/packages/api/tests/moderation-mutewords.test.ts @@ -1,4 +1,4 @@ -import { RichText } from '../src/' +import { RichText, mock, moderatePost } from '../src/' import { hasMutedWord } from '../src/moderation/mutewords' @@ -602,51 +602,90 @@ describe(`hasMutedWord`, () => { }) }) - // TODO - describe.skip(`doesn't mute own post`, () => { + describe(`doesn't mute own post`, () => { it(`does mute if it isn't own post`, () => { - const rt = new RichText({ - text: `Mute words!`, - }) - - const match = hasMutedWord({ - mutedWords: [{ value: 'words', targets: ['content'] }], - text: rt.text, - facets: rt.facets, - outlineTags: [], - }) - - expect(match).toBe(true) + 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 rt = new RichText({ - text: `Mute words!`, - }) - - const match = hasMutedWord({ - mutedWords: [{ value: 'words', targets: ['content'] }], - text: rt.text, - facets: rt.facets, - outlineTags: [], - }) - - expect(match).toBe(false) + 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 match = hasMutedWord({ - mutedWords: [{ value: 'words', targets: ['tags'] }], - text: rt.text, - facets: rt.facets, - outlineTags: [], - }) - - expect(match).toBe(false) + 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) }) }) }) From 3910b50fc4328850cc5bf6325400670c0f58cc4d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 13:22:37 -0800 Subject: [PATCH 17/41] Add mod-authority.test to dev-env --- packages/dev-env/src/mock/index.ts | 357 +++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 24347cfef59..d05382a7f04 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -349,6 +349,363 @@ export async function generateMockSetup(env: TestNetwork) { }, ) + // 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`, + }, + ) + + 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', + 'gore', + 'upsetting', + 'sensitive', + 'self-harm', + 'intolerant', + 'extremist', + 'rude', + 'threat', + 'harassment', + 'spam', + 'engagement-farming', + 'impersonation', + 'inauthentic', + 'scam', + 'security', + 'misleading', + 'misinformation', + 'unsafe-link', + 'illegal', + ], + labelValueDefinitions: [ + { + identifier: 'spam', + blurs: 'content', + severity: 'info', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Spam', + description: + 'Activity that is unsolicited, repetitive, or irrelevant, and intrusive to users. Inclusive of replies, mentions, follows, likes, and notifications that are used in a spammy manner.', + }, + ], + }, + { + identifier: 'impersonation', + blurs: 'none', + severity: 'info', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Impersonation', + description: + 'Attempting to deceive users by mimicking the identity of another person, brand, or entity without authorization. This includes using similar usernames, profile pictures, and posting content that falsely represents the impersonated party.', + }, + ], + }, + { + identifier: 'scam', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Scam', + description: + 'Engaging in deceptive practices aimed at defrauding or misleading users, such as fraudulent offers, phishing attempts, or false claims to solicit personal information or financial gain. This includes fake giveaways, investment scams, and counterfeit sales', + }, + ], + }, + { + identifier: 'intolerant', + blurs: 'content', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Intolerance', + description: + 'Includes hateful, intolerant, or discriminatory views against individuals or groups based on gender, race, religion or other protected characteristics.', + }, + ], + }, + { + identifier: 'self-harm', + blurs: 'content', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Self-Harm', + description: + 'Depicts or promotes self-injurious behavior, including cutting, self-mutilation, or suicide attempts. This includes graphic imagery, discussions that glorify or encourage self-harm, and potentially triggering narratives related to self-injury.', + }, + ], + }, + { + identifier: 'security', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Security Concerns', + description: + "Potentially harmful to users' online safety, including malware distribution, phishing attempts, or signs of a compromised account. This encompasses links to possible malicious software, deceptive practices aimed at stealing personal information, and unusual account behavior indicating unauthorized access.", + }, + ], + }, + { + identifier: 'misleading', + blurs: 'content', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Misleading', + description: + 'Presents false information that misleads users, including manipulated media, text hacks, link misdirects, fake websites, or fraudulent claims. ', + }, + ], + }, + { + identifier: 'threat', + blurs: 'content', + severity: 'info', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Threats', + description: + 'Intentions of violence or harm towards individuals or groups, including direct threats, incitement of violence, or advocating for physical or psychological harm. This includes specific threats of violence, encouragement of dangerous activities, and any communication intended to intimidate or coerce', + }, + ], + }, + { + identifier: 'unsafe-link', + blurs: '', + severity: '', + defaultSetting: '', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Unsafe link', + description: + 'URLs that may lead to harmful websites, including those hosting malware, phishing schemes, or content that violates community guidelines. This includes links that may compromise user security, privacy, or expose them to deceptive or inappropriate content.', + }, + ], + }, + { + identifier: 'illegal', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Illegal', + description: + 'Promotion, sale, or facilitation of goods, services, or activities that violate laws, including but not limited to unauthorized drugs, weapons sales, human trafficking, or promoting dangerous illegal acts. This encompasses any content that encourages or aids in the commission of unlawful behavior', + }, + ], + }, + { + identifier: 'misinformation', + blurs: 'content', + severity: 'info', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Misinformation', + description: + 'Inaccurate or misleading, including unverified claims, facts that have been proven false, and conspiracy theories without credible support. This includes information that could lead to public confusion, health risks, or undermine public trust on important matters, such as elections. ', + }, + ], + }, + { + identifier: 'rude', + blurs: 'content', + severity: 'info', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Rude', + description: + 'May not violate specific community standards but is characterized by discourtesy or impoliteness, including crude language, disrespectful comments, or aggressive tones. This includes interactions that are unnecessarily harsh, confrontational, or lacking in constructive purpose.', + }, + ], + }, + { + identifier: 'extremist', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Extremist', + description: + 'Promotion, support, or advocacy of radical ideologies that advocate for violence, hate, or discrimination against individuals or groups.', + }, + ], + }, + { + identifier: 'harassment', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Harassment', + description: + 'Targeted, aggressive behavior intended to intimidate, bully, or demean individuals or groups. This includes persistent unwanted contact, threats, derogatory comments, and the sharing of personal information without consent.', + }, + ], + }, + { + identifier: 'sensitive', + blurs: 'content', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Sensitive', + description: + 'Could be distressing or triggering to some users, including depictions or discussions of substance abuse, eating disorders, and other mental health issues. It aims to caution viewers about potentially difficult subjects that may affect their well-being or evoke strong emotional responses.', + }, + ], + }, + { + identifier: 'engagement-farming', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Engagement Farming', + description: + 'Pattern of content and/or bulk interactions which seems insincere and with the purpose of building a large following. Inclusive of follow, post, mention and like behaviours, along with accounts that churn these activities to gain attention or disrupt the user experience. ', + }, + ], + }, + { + identifier: 'inauthentic', + blurs: 'content', + severity: 'warn', + defaultSetting: 'hide', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Inauthentic Account', + description: + 'Account is not what it appears. Might be a bot pretending to be a human, or a human misleadingly pretending to be a different demographic or identity group.', + }, + ], + }, + { + identifier: 'upsetting', + blurs: 'content', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Upsetting', + description: + 'Could cause cause emotional distress or discomfort to viewers. This includes intense emotional confrontations, discussions of traumatic events, or any material that could be considered distressing or deeply troubling.', + }, + ], + }, + { + identifier: 'sexual-figurative', + blurs: 'media', + severity: 'none', + defaultSetting: 'ignore', + adultOnly: true, + locales: [ + { + lang: 'en', + name: 'Sexually Suggestive (Cartoon)', + description: + 'Drawn, painted or digital art that is explicitly sexual or employs suggestive elements to evoke sexual themes, through provocative posts, partially concealed nudity to suggest sexual content. ', + }, + ], + }, + { + identifier: 'gore', + blurs: 'media', + severity: 'warn', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Graphic Imagery (Gore)', + description: + 'Graphically depicts violence, injuries, or bodily harm, which may be shocking or disturbing to viewers. This includes scenes of accidents, surgical procedures, or explicit violence in both real-life and fictional contexts.', + }, + ], + }, + ], + }, + createdAt: date.next().value, + }, + ) + } + // create a labeler account { const res = await clients.loggedout.api.com.atproto.server.createAccount({ From f85198c763a3b1b8f84bcbf51e51744301d69cd9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 13:32:51 -0800 Subject: [PATCH 18/41] Rename global label value def gore to graphic-media --- packages/api/definitions/labels.json | 2 +- packages/api/docs/labels.md | 2 +- packages/api/src/moderation/const/labels.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json index 1118b595386..913ad4365c6 100644 --- a/packages/api/definitions/labels.json +++ b/packages/api/definitions/labels.json @@ -146,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 index a26bec2c9d3..f0e713961b4 100644 --- a/packages/api/docs/labels.md +++ b/packages/api/docs/labels.md @@ -82,7 +82,7 @@ The kind of UI behavior used when a warning must be applied. undefined - gore + graphic-media ✅ adult undefined diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts index 9dbd9219abd..53be497191f 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -8,13 +8,13 @@ export type KnownLabelValue = | 'porn' | 'sexual' | 'nudity' - | 'gore' + | 'graphic-media' export const DEFAULT_LABEL_SETTINGS: Record = { porn: 'hide', sexual: 'warn', nudity: 'ignore', - gore: 'warn', + 'graphic-media': 'warn', } export const LABELS: Record = @@ -171,8 +171,8 @@ export const LABELS: Record = }, locales: [], }, - gore: { - identifier: 'gore', + 'graphic-media': { + identifier: 'graphic-media', flags: ['adult'], configurable: true, defaultSetting: 'warn', From f24314d8cbf6f27799defb1205bbf065fb81bd6f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 13:33:02 -0800 Subject: [PATCH 19/41] Fix typo --- packages/api/scripts/code/labels.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: [] }]), ), From 4d643567490fc848008ce25a85b876bd76ea7b96 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 13:33:11 -0800 Subject: [PATCH 20/41] Stop converting old label values --- packages/api/src/bsky-agent.ts | 11 +---------- packages/api/tests/moderation-prefs.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 64fb4cd1c89..6f0bf88e03b 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -851,7 +851,6 @@ async function updateFeedPreferences( function adjustLegacyContentLabelPref( pref: AppBskyActorDefs.ContentLabelPref, ): AppBskyActorDefs.ContentLabelPref { - let label = pref.label let visibility = pref.visibility // adjust legacy values @@ -859,15 +858,7 @@ function adjustLegacyContentLabelPref( visibility = 'ignore' } - // adjust legacy labels - if (label === 'nsfw') { - label = 'porn' - } - if (label === 'suggestive') { - label = 'sexual' - } - - return { ...pref, label, visibility } + return { ...pref, visibility } } /** diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index 93255941fce..fcfe36400f7 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -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', }, ], @@ -60,7 +60,7 @@ describe('agent', () => { porn: 'ignore', nudity: 'ignore', sexual: 'ignore', - gore: 'ignore', + 'graphic-media': 'ignore', }, labelers: [], hiddenPosts: [], From 116ee39e3538d7b5b1db925d5fc5dfe55d398662 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 13:58:34 -0800 Subject: [PATCH 21/41] Update label target=profile behaviors: dont filter content on hide, dont blur display names, but do show the info cards --- packages/api/src/moderation/decision.ts | 10 ++- packages/api/src/moderation/types.ts | 1 + packages/api/src/moderation/util.ts | 8 +- .../api/tests/moderation-behaviors.test.ts | 16 ++-- .../tests/moderation-custom-labels.test.ts | 89 +++++++------------ packages/api/tests/moderation.test.ts | 6 +- 6 files changed, 53 insertions(+), 77 deletions(-) diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 0a072bd516e..a1884c61848 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -113,7 +113,14 @@ export class ModerationDecision { 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) } @@ -298,6 +305,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/types.ts b/packages/api/src/moderation/types.ts index 6f662049283..4c5f43c345f 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -119,6 +119,7 @@ export type ModerationCause = source: ModerationCauseSource label: Label labelDef: InterpretedLabelValueDefinition + target: LabelTarget setting: LabelPreference behavior: ModerationBehavior noOverride: boolean diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts index afb0bc474e0..2e66bc47730 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -47,10 +47,8 @@ export function interpretLabelValueDefinition( behaviors.account.contentList = 'blur' 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 = def.adultOnly ? 'blur' : alertOrInform @@ -62,6 +60,7 @@ export function interpretLabelValueDefinition( 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' @@ -74,6 +73,7 @@ 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 diff --git a/packages/api/tests/moderation-behaviors.test.ts b/packages/api/tests/moderation-behaviors.test.ts index 68ae09983b8..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": { @@ -354,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)": { @@ -378,7 +373,6 @@ const SCENARIOS: SuiteScenarios = { behaviors: { avatar: ['blur'], banner: ['blur'], - contentList: ['filter'], }, }, "Blur-media label ('porn') on author account (hide)": { @@ -498,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': { @@ -522,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': { @@ -793,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 f9054e246ce..48c4158d50a 100644 --- a/packages/api/tests/moderation-custom-labels.test.ts +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -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: [], }, @@ -107,14 +95,12 @@ const TESTS: Scenario[] = [ contentMedia: ['blur'], }, profile: { - profileList: ['filter'], + profileList: ['alert'], profileView: ['alert'], avatar: ['blur'], banner: ['blur'], - contentList: ['filter'], }, post: { - profileList: ['filter'], contentList: ['filter'], contentMedia: ['blur'], }, @@ -131,14 +117,12 @@ const TESTS: Scenario[] = [ contentMedia: ['blur'], }, profile: { - profileList: ['filter'], + profileList: ['inform'], profileView: ['inform'], avatar: ['blur'], banner: ['blur'], - contentList: ['filter'], }, post: { - profileList: ['filter'], contentList: ['filter'], contentMedia: ['blur'], }, @@ -154,13 +138,10 @@ const TESTS: Scenario[] = [ contentMedia: ['blur'], }, profile: { - profileList: ['filter'], avatar: ['blur'], banner: ['blur'], - contentList: ['filter'], }, post: { - profileList: ['filter'], contentList: ['filter'], contentMedia: ['blur'], }, @@ -176,12 +157,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 +175,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 +190,8 @@ const TESTS: Scenario[] = [ profileList: ['filter'], contentList: ['filter'], }, - profile: { - profileList: ['filter'], - contentList: ['filter'], - }, + profile: {}, post: { - profileList: ['filter'], contentList: ['filter'], }, }, @@ -300,27 +273,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 || [], + ) }, ) }) diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index 6d4cc998ed0..8b84afab699 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -354,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([]) @@ -512,7 +512,7 @@ describe('Moderation', () => { modOpts, ) - expect(res1.ui('profileList')).toBeModerationResult(['filter']) + expect(res1.ui('profileList')).toBeModerationResult([]) expect(res1.ui('profileView')).toBeModerationResult([]) expect(res1.ui('avatar')).toBeModerationResult([]) expect(res1.ui('banner')).toBeModerationResult([]) @@ -637,7 +637,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([]) From 4d689e8c54784314d53f3bdc633c475d4934f536 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 14:14:33 -0800 Subject: [PATCH 22/41] Update label target=account behaviors: dont blur media of content --- packages/api/src/moderation/util.ts | 1 - packages/api/tests/moderation-custom-labels.test.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts index 2e66bc47730..aaf800aa8aa 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -58,7 +58,6 @@ export function interpretLabelValueDefinition( 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 diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts index 48c4158d50a..fb814e4f953 100644 --- a/packages/api/tests/moderation-custom-labels.test.ts +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -92,7 +92,6 @@ const TESTS: Scenario[] = [ avatar: ['blur'], banner: ['blur'], contentList: ['filter'], - contentMedia: ['blur'], }, profile: { profileList: ['alert'], @@ -114,7 +113,6 @@ const TESTS: Scenario[] = [ avatar: ['blur'], banner: ['blur'], contentList: ['filter'], - contentMedia: ['blur'], }, profile: { profileList: ['inform'], @@ -135,7 +133,6 @@ const TESTS: Scenario[] = [ avatar: ['blur'], banner: ['blur'], contentList: ['filter'], - contentMedia: ['blur'], }, profile: { avatar: ['blur'], From a72c70294931ad6116e38e3085b6fd0d98b06948 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sat, 9 Mar 2024 14:51:47 -0800 Subject: [PATCH 23/41] Add muteword moderation behaviors --- packages/api/src/moderation/decision.ts | 12 ++++++++++++ packages/api/src/moderation/types.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index a1884c61848..086eed055c2 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -2,6 +2,7 @@ import { AppBskyGraphDefs } from '../client/index' import { BLOCK_BEHAVIOR, MUTE_BEHAVIOR, + MUTEWORD_BEHAVIOR, HIDE_BEHAVIOR, NOOP_BEHAVIOR, Label, @@ -101,6 +102,17 @@ export class ModerationDecision { } else if (MUTE_BEHAVIOR[context] === 'inform') { ui.informs.push(cause) } + } else if (cause.type === 'mute-word') { + if (context === 'contentList') { + ui.filters.push(cause) + } + 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) diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index 4c5f43c345f..c0bf51350bd 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', From c6e1e7a0a069e6dafbaa2c1f0d4e6fcd21859977 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 10 Mar 2024 13:02:29 -0700 Subject: [PATCH 24/41] Fix mock label defs --- packages/dev-env/src/mock/index.ts | 42 +++++++++++++++--------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index d05382a7f04..9ff2fb24860 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -402,7 +402,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'spam', blurs: 'content', - severity: 'info', + severity: 'inform', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -417,7 +417,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'impersonation', blurs: 'none', - severity: 'info', + severity: 'inform', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -432,7 +432,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'scam', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -447,7 +447,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'intolerant', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -462,7 +462,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'self-harm', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -477,7 +477,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'security', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -492,7 +492,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'misleading', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -507,7 +507,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'threat', blurs: 'content', - severity: 'info', + severity: 'inform', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -521,9 +521,9 @@ export async function generateMockSetup(env: TestNetwork) { }, { identifier: 'unsafe-link', - blurs: '', - severity: '', - defaultSetting: '', + blurs: 'content', + severity: 'alert', + defaultSetting: 'hide', adultOnly: false, locales: [ { @@ -537,7 +537,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'illegal', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -552,7 +552,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'misinformation', blurs: 'content', - severity: 'info', + severity: 'inform', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -567,7 +567,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'rude', blurs: 'content', - severity: 'info', + severity: 'inform', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -582,7 +582,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'extremist', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -597,7 +597,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'harassment', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -612,7 +612,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'sensitive', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -627,7 +627,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'engagement-farming', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -642,7 +642,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'inauthentic', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'hide', adultOnly: false, locales: [ @@ -657,7 +657,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'upsetting', blurs: 'content', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ @@ -687,7 +687,7 @@ export async function generateMockSetup(env: TestNetwork) { { identifier: 'gore', blurs: 'media', - severity: 'warn', + severity: 'alert', defaultSetting: 'warn', adultOnly: false, locales: [ From 388309d16b6397fe9ad1d3f3f95a8156e84da7ba Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 10 Mar 2024 13:57:15 -0700 Subject: [PATCH 25/41] Implement quote-post moderation handling --- packages/api/src/moderation/decision.ts | 93 +++--- packages/api/src/moderation/index.ts | 28 +- .../src/moderation/subjects/feed-generator.ts | 11 +- .../src/moderation/subjects/notification.ts | 8 +- packages/api/src/moderation/subjects/post.ts | 42 ++- .../api/src/moderation/subjects/user-list.ts | 15 +- packages/api/src/moderation/types.ts | 43 ++- .../tests/moderation-custom-labels.test.ts | 3 + .../api/tests/moderation-quoteposts.test.ts | 277 ++++++++++++++++++ .../api/tests/util/moderation-behavior.ts | 2 + 10 files changed, 446 insertions(+), 76 deletions(-) create mode 100644 packages/api/tests/moderation-quoteposts.test.ts diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts index 086eed055c2..b8a76206e67 100644 --- a/packages/api/src/moderation/decision.ts +++ b/packages/api/src/moderation/decision.ts @@ -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,46 +90,54 @@ 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 (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) + 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' && cause.target === 'account') { @@ -137,15 +152,17 @@ export class ModerationDecision { 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) } } } diff --git a/packages/api/src/moderation/index.ts b/packages/api/src/moderation/index.ts index 86d8ee67988..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, @@ -37,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/subjects/feed-generator.ts b/packages/api/src/moderation/subjects/feed-generator.ts index d87e62e9044..a5acd628a76 100644 --- a/packages/api/src/moderation/subjects/feed-generator.ts +++ b/packages/api/src/moderation/subjects/feed-generator.ts @@ -1,10 +1,15 @@ 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() + return ModerationDecision.merge( + 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 6cfaff0d894..9fef4a598e8 100644 --- a/packages/api/src/moderation/subjects/post.ts +++ b/packages/api/src/moderation/subjects/post.ts @@ -9,6 +9,8 @@ import { } from '../../client' import { ModerationSubjectPost, ModerationOpts } from '../types' import { hasMutedWord } from '../mutewords' +import { decideAccount } from './account' +import { decideProfile } from './profile' export function decidePost( subject: ModerationSubjectPost, @@ -28,7 +30,45 @@ export function decidePost( acc.addMutedWord(checkMutedWords(subject, opts.prefs.mutedWords)) } - return acc + 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), + ) } function checkHiddenPost( diff --git a/packages/api/src/moderation/subjects/user-list.ts b/packages/api/src/moderation/subjects/user-list.ts index ad7cd861c49..39a0daf85ee 100644 --- a/packages/api/src/moderation/subjects/user-list.ts +++ b/packages/api/src/moderation/subjects/user-list.ts @@ -1,10 +1,19 @@ +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 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(account, profile) } diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index c0bf51350bd..bbf8d842f23 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -115,9 +115,24 @@ 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 @@ -128,10 +143,26 @@ export type ModerationCause = 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: 'mute-word'; source: ModerationCauseSource; priority: 6 } - | { type: 'hidden'; source: ModerationCauseSource; priority: 6 } export interface ModerationPrefsLabeler { did: string diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts index fb814e4f953..3e051fb0498 100644 --- a/packages/api/tests/moderation-custom-labels.test.ts +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -307,6 +307,8 @@ function modOpts(blurs: string, severity: string): ModerationOpts { labels: { custom: 'hide' }, }, ], + mutedWords: [], + hiddenPosts: [], }, labelDefs: { 'did:web:labeler.test': [makeCustomLabel(blurs, severity)], @@ -323,6 +325,7 @@ function makeCustomLabel( identifier: 'custom', blurs, severity, + defaultSetting: 'warn', locales: [], }, 'did:web:labeler.test', 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/util/moderation-behavior.ts b/packages/api/tests/util/moderation-behavior.ts index d7347c66254..0f33ec65b7e 100644 --- a/packages/api/tests/util/moderation-behavior.ts +++ b/packages/api/tests/util/moderation-behavior.ts @@ -260,6 +260,8 @@ export class ModerationBehaviorSuiteRunner { labels: {}, }, ], + mutedWords: [], + hiddenPosts: [], }, } } From ce6507230cb53dfea33f95098b90f8cc70155300 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 10 Mar 2024 14:21:04 -0700 Subject: [PATCH 26/41] Add adult content test --- packages/api/tests/moderation.test.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index 8b84afab699..320e1b105d0 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -650,4 +650,51 @@ describe('Moderation', () => { 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']) + }) }) From 3337adad97c7213052c840144f9ccee54b4b3ca6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 11 Mar 2024 18:18:33 -0500 Subject: [PATCH 27/41] Handle sync legacy labels (#2291) * Handle sync legacy labels * Remap values on read * Filter out double-written legacy label values * Better naming, fix types --- packages/api/src/bsky-agent.ts | 75 +++++++++++ packages/api/tests/moderation-prefs.test.ts | 130 +++++++++++++++++++- 2 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 6f0bf88e03b..c3a10359f42 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -432,6 +432,10 @@ export class BskyAgent extends AtpAgent { } } + prefs.moderationPrefs.labels = remapLegacyLabels( + prefs.moderationPrefs.labels, + ) + // automatically configure the client this.configureLabelersHeader(prefsArrayToLabelerDids(res.data.preferences)) @@ -510,6 +514,8 @@ export class BskyAgent extends AtpAgent { pref.label === key && pref.labelerDid === labelerDid, ) + let legacyLabelPref: AppBskyActorDefs.ContentLabelPref | undefined + if (labelPref) { labelPref.visibility = value } else { @@ -520,6 +526,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) => @@ -527,6 +567,17 @@ 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] : []) }) } @@ -861,6 +912,30 @@ function adjustLegacyContentLabelPref( 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', + } + + for (const labelName in _labels) { + const newLabelName = legacyToNewMap[labelName]! + if (newLabelName) { + _labels[newLabelName] = _labels[labelName] + } + } + + return _labels +} + /** * A helper to get the currently enabled labelers from the full preferences array */ diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index fcfe36400f7..0a9a768ce0c 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -175,7 +175,7 @@ describe('agent', () => { interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, - labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' }, + labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', nsfw: 'ignore' }, labelers: [ { did: 'did:plc:other', @@ -204,4 +204,132 @@ describe('agent', () => { }, }) }) + + 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') + }) }) From af1e6dd4699cf27c4f8a5544a7899f614a7bc8a5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 11 Mar 2024 18:35:20 -0500 Subject: [PATCH 28/41] Fix test --- packages/api/tests/bsky-agent.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index c28fb1b4c05..edeed48fa89 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -1123,6 +1123,7 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', porn: 'ignore', }, labelers: [ @@ -1167,6 +1168,7 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', porn: 'ignore', }, labelers: [ @@ -1207,6 +1209,7 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', porn: 'ignore', }, labelers: [ @@ -1247,6 +1250,7 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', porn: 'ignore', }, labelers: [ @@ -1298,6 +1302,7 @@ describe('agent', () => { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', porn: 'ignore', }, labelers: [ @@ -1340,6 +1345,11 @@ describe('agent', () => { label: 'porn', visibility: 'ignore', }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'nsfw', + visibility: 'ignore', + }, { $type: 'app.bsky.actor.defs#labelersPref', labelers: [ From 9d1d990d86cce157022eb29085cf56f5c04d819d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 10:37:13 -0700 Subject: [PATCH 29/41] Update moderation docs in sdk --- packages/api/README.md | 99 ++++----- packages/api/docs/labels.md | 90 -------- packages/api/docs/moderation.md | 286 +++++++++++++++++-------- packages/api/package.json | 3 +- packages/api/scripts/docs/labels.mjs | 87 -------- packages/api/scripts/generate-docs.mjs | 3 - 6 files changed, 233 insertions(+), 335 deletions(-) delete mode 100644 packages/api/docs/labels.md delete mode 100644 packages/api/scripts/docs/labels.mjs delete mode 100644 packages/api/scripts/generate-docs.mjs 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/docs/labels.md b/packages/api/docs/labels.md deleted file mode 100644 index f0e713961b4..00000000000 --- a/packages/api/docs/labels.md +++ /dev/null @@ -1,90 +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
!warn❌ (undefined)no-selfundefined
!no-unauthenticated❌ (undefined)no-override, unauthedundefined
pornadultundefined
sexualadultundefined
nudityundefined
graphic-mediaadultundefined
diff --git a/packages/api/docs/moderation.md b/packages/api/docs/moderation.md index 7e8d09dd1aa..29d47eaab9c 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,231 @@ 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 subscribed labelers and their label settings + labelers: [ + { + did: 'did:plc:1234...', + labels: { + porn: 'hide', + sexual: 'warn', + nudity: 'ignore', + // ... + } + } + ], - // the global label settings (used on self-labels) - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... + mutedWords: [/* ... */], + hiddenPosts: [/* ... */] }, - // the per-labeler settings - labelers: [ - { - labeler: { - did: '...', - displayName: 'My mod service' - }, - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... - } - } - ] + // 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: -Applications need to produce the [Profile Moderation Behaviors](./moderation-behaviors/profiles.md) using the `moderateProfile()` API. +```typescript +const res = moderatePost(post, moderationOptions) +``` + +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) /* => + +ModerationUI { + filter: boolean // should the content be removed from the interface? + blur: boolean // should the content be put behind a cover? + alert: boolean // should an alert be put on the content? (negative) + inform: boolean // should an informational notice be put on the content? (neutral) + noOverride: boolean // if blur=true, should the UI disable opening the cover? + + // the reasons for each of the flags: + filters: ModerationCause[] + blurs: ModerationCause[] + alerts: ModerationCause[] + informs: ModerationCause[] +} +*/ +``` + +There are multiple UI contexts available: -const profileMod = moderateProfile(profileView, getOpts()) +- `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 -if (profileMod.account.filter) { - // don't render in discovery +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.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 +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.account.alert) { - // render a warning on the account (use profileMod.account.cause to explain) -} -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 } ``` + diff --git a/packages/api/package.json b/packages/api/package.json index 0bdba638646..0dc1a3339b7 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", 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 {} From 77721e7698843041db7bbb5b9e0aad730e4c0fe1 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 10:47:41 -0700 Subject: [PATCH 30/41] Update to new atproto-accept-labelers header behavior --- packages/api/src/agent.ts | 32 +++++++++++--------------------- packages/api/src/types.ts | 2 +- packages/api/tests/agent.test.ts | 27 ++++++++++++++++----------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 732d82715e6..5641ac6a777 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -53,9 +53,9 @@ export class AtpAgent { static fetch: AtpAgentFetchHandler | undefined = defaultFetchHandler /** - * The moderation authorities to be used across all requests + * The labelers to be used across all requests with the takedown capability */ - static modAuthoritiesHeader: string[] = [BSKY_LABELER_DID] + static appLabelers: string[] = [BSKY_LABELER_DID] /** * Configures the API globally. @@ -64,8 +64,8 @@ export class AtpAgent { if (opts.fetch) { AtpAgent.fetch = opts.fetch } - if (opts.modAuthorities) { - AtpAgent.modAuthoritiesHeader = opts.modAuthorities + if (opts.appLabelers) { + AtpAgent.appLabelers = opts.appLabelers } } @@ -224,23 +224,13 @@ export class AtpAgent { authorization: `Bearer ${this.session.accessJwt}`, } } - if (AtpAgent.modAuthoritiesHeader.length) { - reqHeaders = { - ...reqHeaders, - 'atproto-mod-authorities': AtpAgent.modAuthoritiesHeader - .filter((str) => str.startsWith('did:')) - .slice(0, MAX_MOD_AUTHORITIES) - .join(','), - } - } - if (this.labelersHeader.length) { - reqHeaders = { - ...reqHeaders, - 'atproto-labelers': this.labelersHeader - .filter((str) => str.startsWith('did:')) - .slice(0, MAX_LABELERS) - .join(','), - } + 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/types.ts b/packages/api/src/types.ts index 3834d1746c9..0f3c0191b33 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -68,7 +68,7 @@ export type AtpAgentFetchHandler = ( */ export interface AtpAgentGlobalOpts { fetch?: AtpAgentFetchHandler - modAuthorities?: string[] + appLabelers?: string[] } /** diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index e4ee8dd251f..7bc1c1d1fb0 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -482,24 +482,27 @@ describe('agent', () => { }) }) - describe('Mod authorities header', () => { - it('adds the authorities header as expected', async () => { + describe('App labelers header', () => { + it('adds the labelers header as expected', async () => { const server = await createHeaderEchoServer(15991) const agent = new AtpAgent({ service: 'http://localhost:15991' }) const agent2 = new AtpAgent({ service: 'http://localhost:15991' }) const res1 = await agent.com.atproto.server.describeServer() - expect(res1.data['atproto-mod-authorities']).toEqual(BSKY_LABELER_DID) + expect(res1.data['atproto-accept-labelers']).toEqual( + `${BSKY_LABELER_DID};redact`, + ) - AtpAgent.configure({ modAuthorities: ['did:plc:test1', 'did:plc:test2'] }) + AtpAgent.configure({ appLabelers: ['did:plc:test1', 'did:plc:test2'] }) const res2 = await agent.com.atproto.server.describeServer() - expect(res2.data['atproto-mod-authorities']).toEqual( - 'did:plc:test1,did:plc:test2', + 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-mod-authorities']).toEqual( - 'did:plc:test1,did:plc:test2', + 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)) }) @@ -512,12 +515,14 @@ describe('agent', () => { 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)) From 54330cec470dc42d0377025c496ae68a7f134839 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 10:54:48 -0700 Subject: [PATCH 31/41] Add getLabelDefinitions() helper method --- packages/api/src/bsky-agent.ts | 52 +++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index c3a10359f42..106167675e5 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,9 +13,14 @@ import { BskyThreadViewPreference, BskyInterestsPreference, } from './types' -import { LabelPreference } from './moderation/types' +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, @@ -102,6 +108,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, @@ -985,3 +1022,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 +} From ead8c670383476a6f77da30ec9ecd1e56210415b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 11:23:38 -0700 Subject: [PATCH 32/41] Add proxy header support to agent --- packages/api/docs/moderation.md | 11 ++++++ packages/api/src/agent.ts | 50 ++++++++++++++++++++++----- packages/api/src/bsky-agent.ts | 8 +++++ packages/api/src/types.ts | 5 +++ packages/api/tests/agent.test.ts | 33 ++++++++++++++++++ packages/api/tests/bsky-agent.test.ts | 7 ++++ 6 files changed, 106 insertions(+), 8 deletions(-) diff --git a/packages/api/docs/moderation.md b/packages/api/docs/moderation.md index 29d47eaab9c..6f49288775d 100644 --- a/packages/api/docs/moderation.md +++ b/packages/api/docs/moderation.md @@ -245,3 +245,14 @@ for (const inform of mod.ui('contentList').informs) { } ``` +## 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'} +}) +``` \ No newline at end of file diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 5641ac6a777..87cfd4bcfb6 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -17,6 +17,7 @@ import { AtpAgentGlobalOpts, AtpPersistSessionHandler, AtpAgentOpts, + AtprotoServiceType, } from './types' import { BSKY_LABELER_DID } from './const' @@ -33,15 +34,12 @@ export class AtpAgent { api: AtpServiceClient session?: AtpSessionData 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 @@ -80,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? */ @@ -104,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. */ @@ -224,6 +252,12 @@ export class AtpAgent { authorization: `Bearer ${this.session.accessJwt}`, } } + if (this.proxyHeader) { + reqHeaders = { + ...reqHeaders, + 'atproto-proxy': this.proxyHeader, + } + } reqHeaders = { ...reqHeaders, 'atproto-accept-labelers': AtpAgent.appLabelers diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 106167675e5..e51347bca7e 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -44,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 } diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 0f3c0191b33..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 */ diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index 7bc1c1d1fb0..b4ad10802db 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -27,6 +27,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)[] = [] @@ -528,6 +536,31 @@ describe('agent', () => { await new Promise((r) => server.close(r)) }) }) + + describe('configureProxyHeader', () => { + it('adds the proxy header as expected', async () => { + const server = await createHeaderEchoServer(15992) + const agent = new AtpAgent({ service: 'http://localhost:15992' }) + + 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)) + }) + }) }) const createPost = async (agent: AtpAgent) => { diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index edeed48fa89..bae98dfe65d 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -33,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 }) From 394d5c8082c3099394bed31ff164b823f22921fb Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 12:41:41 -0700 Subject: [PATCH 33/41] Update mock moderation --- packages/dev-env/src/mock/index.ts | 115 ++++++++++------------------- 1 file changed, 40 insertions(+), 75 deletions(-) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 9ff2fb24860..52ee009febc 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -378,25 +378,23 @@ export async function generateMockSetup(env: TestNetwork) { 'nudity', 'sexual-figurative', 'graphic-media', - 'gore', - 'upsetting', - 'sensitive', 'self-harm', - 'intolerant', + 'sensitive', 'extremist', - 'rude', + 'intolerant', 'threat', - 'harassment', - 'spam', - 'engagement-farming', + 'rude', + 'illicit', + 'security', + 'unsafe-link', 'impersonation', - 'inauthentic', + 'misinformation', 'scam', - 'security', + 'engagement-farming', + 'spam', + 'rumor', 'misleading', - 'misinformation', - 'unsafe-link', - 'illegal', + 'inauthentic', ], labelValueDefinitions: [ { @@ -410,7 +408,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Spam', description: - 'Activity that is unsolicited, repetitive, or irrelevant, and intrusive to users. Inclusive of replies, mentions, follows, likes, and notifications that are used in a spammy manner.', + 'Unwanted, repeated, or unrelated actions that bother users.', }, ], }, @@ -425,7 +423,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Impersonation', description: - 'Attempting to deceive users by mimicking the identity of another person, brand, or entity without authorization. This includes using similar usernames, profile pictures, and posting content that falsely represents the impersonated party.', + 'Pretending to be someone else without permission.', }, ], }, @@ -439,8 +437,7 @@ export async function generateMockSetup(env: TestNetwork) { { lang: 'en', name: 'Scam', - description: - 'Engaging in deceptive practices aimed at defrauding or misleading users, such as fraudulent offers, phishing attempts, or false claims to solicit personal information or financial gain. This includes fake giveaways, investment scams, and counterfeit sales', + description: 'Scams, phishing & fraud.', }, ], }, @@ -454,8 +451,7 @@ export async function generateMockSetup(env: TestNetwork) { { lang: 'en', name: 'Intolerance', - description: - 'Includes hateful, intolerant, or discriminatory views against individuals or groups based on gender, race, religion or other protected characteristics.', + description: 'Discrimination against protected groups.', }, ], }, @@ -470,7 +466,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Self-Harm', description: - 'Depicts or promotes self-injurious behavior, including cutting, self-mutilation, or suicide attempts. This includes graphic imagery, discussions that glorify or encourage self-harm, and potentially triggering narratives related to self-injury.', + 'Promotes self-harm, including graphic images, glorifying discussions, or triggering stories.', }, ], }, @@ -485,7 +481,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Security Concerns', description: - "Potentially harmful to users' online safety, including malware distribution, phishing attempts, or signs of a compromised account. This encompasses links to possible malicious software, deceptive practices aimed at stealing personal information, and unusual account behavior indicating unauthorized access.", + 'May be unsafe and could harm your device, steal your info, or get your account hacked.', }, ], }, @@ -500,7 +496,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Misleading', description: - 'Presents false information that misleads users, including manipulated media, text hacks, link misdirects, fake websites, or fraudulent claims. ', + 'Altered images/videos, deceptive links, or false statements.', }, ], }, @@ -515,7 +511,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Threats', description: - 'Intentions of violence or harm towards individuals or groups, including direct threats, incitement of violence, or advocating for physical or psychological harm. This includes specific threats of violence, encouragement of dangerous activities, and any communication intended to intimidate or coerce', + 'Promotes violence or harm towards others, including threats, incitement, or advocacy of harm.', }, ], }, @@ -530,12 +526,12 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Unsafe link', description: - 'URLs that may lead to harmful websites, including those hosting malware, phishing schemes, or content that violates community guidelines. This includes links that may compromise user security, privacy, or expose them to deceptive or inappropriate content.', + 'Links to harmful sites with malware, phishing, or violating content that risk security and privacy.', }, ], }, { - identifier: 'illegal', + identifier: 'illicit', blurs: 'content', severity: 'alert', defaultSetting: 'hide', @@ -543,9 +539,9 @@ export async function generateMockSetup(env: TestNetwork) { locales: [ { lang: 'en', - name: 'Illegal', + name: 'Illicit', description: - 'Promotion, sale, or facilitation of goods, services, or activities that violate laws, including but not limited to unauthorized drugs, weapons sales, human trafficking, or promoting dangerous illegal acts. This encompasses any content that encourages or aids in the commission of unlawful behavior', + 'Promoting or selling potentially illicit goods, services, or activities.', }, ], }, @@ -560,42 +556,42 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Misinformation', description: - 'Inaccurate or misleading, including unverified claims, facts that have been proven false, and conspiracy theories without credible support. This includes information that could lead to public confusion, health risks, or undermine public trust on important matters, such as elections. ', + 'Spreading false or misleading info, including unverified claims and harmful conspiracy theories.', }, ], }, { - identifier: 'rude', + identifier: 'rumor', blurs: 'content', severity: 'inform', - defaultSetting: 'hide', + defaultSetting: 'warn', adultOnly: false, locales: [ { lang: 'en', - name: 'Rude', + name: 'Rumor', description: - 'May not violate specific community standards but is characterized by discourtesy or impoliteness, including crude language, disrespectful comments, or aggressive tones. This includes interactions that are unnecessarily harsh, confrontational, or lacking in constructive purpose.', + 'Approach with caution, as these claims lack evidence from credible sources.', }, ], }, { - identifier: 'extremist', + identifier: 'rude', blurs: 'content', - severity: 'alert', + severity: 'inform', defaultSetting: 'hide', adultOnly: false, locales: [ { lang: 'en', - name: 'Extremist', + name: 'Rude', description: - 'Promotion, support, or advocacy of radical ideologies that advocate for violence, hate, or discrimination against individuals or groups.', + 'Rude or impolite, including crude language and disrespectful comments, without constructive purpose.', }, ], }, { - identifier: 'harassment', + identifier: 'extremist', blurs: 'content', severity: 'alert', defaultSetting: 'hide', @@ -603,9 +599,9 @@ export async function generateMockSetup(env: TestNetwork) { locales: [ { lang: 'en', - name: 'Harassment', + name: 'Extremist', description: - 'Targeted, aggressive behavior intended to intimidate, bully, or demean individuals or groups. This includes persistent unwanted contact, threats, derogatory comments, and the sharing of personal information without consent.', + 'Radical views advocating violence, hate, or discrimination against individuals or groups.', }, ], }, @@ -620,7 +616,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Sensitive', description: - 'Could be distressing or triggering to some users, including depictions or discussions of substance abuse, eating disorders, and other mental health issues. It aims to caution viewers about potentially difficult subjects that may affect their well-being or evoke strong emotional responses.', + 'May be upsetting, covering topics like substance abuse or mental health issues, cautioning sensitive viewers.', }, ], }, @@ -635,7 +631,7 @@ export async function generateMockSetup(env: TestNetwork) { lang: 'en', name: 'Engagement Farming', description: - 'Pattern of content and/or bulk interactions which seems insincere and with the purpose of building a large following. Inclusive of follow, post, mention and like behaviours, along with accounts that churn these activities to gain attention or disrupt the user experience. ', + 'Insincere content or bulk actions aimed at gaining followers, including frequent follows, posts, and likes.', }, ], }, @@ -649,23 +645,7 @@ export async function generateMockSetup(env: TestNetwork) { { lang: 'en', name: 'Inauthentic Account', - description: - 'Account is not what it appears. Might be a bot pretending to be a human, or a human misleadingly pretending to be a different demographic or identity group.', - }, - ], - }, - { - identifier: 'upsetting', - blurs: 'content', - severity: 'alert', - defaultSetting: 'warn', - adultOnly: false, - locales: [ - { - lang: 'en', - name: 'Upsetting', - description: - 'Could cause cause emotional distress or discomfort to viewers. This includes intense emotional confrontations, discussions of traumatic events, or any material that could be considered distressing or deeply troubling.', + description: 'Bot or a person pretending to be someone else.', }, ], }, @@ -673,29 +653,14 @@ export async function generateMockSetup(env: TestNetwork) { identifier: 'sexual-figurative', blurs: 'media', severity: 'none', - defaultSetting: 'ignore', + defaultSetting: 'show', adultOnly: true, locales: [ { lang: 'en', name: 'Sexually Suggestive (Cartoon)', description: - 'Drawn, painted or digital art that is explicitly sexual or employs suggestive elements to evoke sexual themes, through provocative posts, partially concealed nudity to suggest sexual content. ', - }, - ], - }, - { - identifier: 'gore', - blurs: 'media', - severity: 'alert', - defaultSetting: 'warn', - adultOnly: false, - locales: [ - { - lang: 'en', - name: 'Graphic Imagery (Gore)', - description: - 'Graphically depicts violence, injuries, or bodily harm, which may be shocking or disturbing to viewers. This includes scenes of accidents, surgical procedures, or explicit violence in both real-life and fictional contexts.', + 'Art with explicit or suggestive sexual themes, including provocative imagery or partial nudity.', }, ], }, From bd81e10d8818e3f02a6be78d0d7b464ae8f43033 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 15:30:16 -0500 Subject: [PATCH 34/41] lint --- packages/api/docs/moderation.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/api/docs/moderation.md b/packages/api/docs/moderation.md index 6f49288775d..571660d00fa 100644 --- a/packages/api/docs/moderation.md +++ b/packages/api/docs/moderation.md @@ -93,7 +93,7 @@ moderatePost(post, { }) ``` -To gather the label definitions (`labelDefs`) see the *Labelers* section below. +To gather the label definitions (`labelDefs`) see the _Labelers_ section below. ## Labelers @@ -101,7 +101,7 @@ Labelers are services that provide moderation labels. Your application will typi ```typescript BskyAgent.configure({ - appLabelers: ['did:web:my-labeler.com'] + appLabelers: ['did:web:my-labeler.com'], }) ``` @@ -144,7 +144,7 @@ The label value definition are custom labels which only apply to that labeler. Y Here is how to do this: ```typescript -import {BskyAgent} from '@atproto/api' +import { BskyAgent } from '@atproto/api' const agent = new BskyAgent() // assume `agent` is a signed in session @@ -154,7 +154,7 @@ const labelDefs = await agent.getLabelDefinitions(prefs) moderatePost(post, { userDid: agent.session.did, prefs: prefs.moderationPrefs, - labelDefs + labelDefs, }) ``` @@ -169,7 +169,7 @@ import { moderateNotification, moderateFeedGen, moderateUserList, - moderateLabeler + moderateLabeler, } from '@atproto/api' ``` @@ -250,9 +250,11 @@ for (const inform of mod.ui('contentList').informs) { 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'} -}) -``` \ No newline at end of file +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' }, + }) +``` From b666af52e2c022e5d3ad3b0c8271ccb6a62a206b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 16:04:01 -0700 Subject: [PATCH 35/41] Implement moderation for userlists and feedgens --- .../src/moderation/subjects/feed-generator.ts | 11 ++++- .../api/src/moderation/subjects/user-list.ts | 41 +++++++++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/api/src/moderation/subjects/feed-generator.ts b/packages/api/src/moderation/subjects/feed-generator.ts index a5acd628a76..3afada34b2d 100644 --- a/packages/api/src/moderation/subjects/feed-generator.ts +++ b/packages/api/src/moderation/subjects/feed-generator.ts @@ -7,8 +7,17 @@ export function decideFeedGenerator( subject: ModerationSubjectFeedGenerator, opts: ModerationOpts, ): ModerationDecision { - // TODO handle labels applied on the feed generator itself + 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/user-list.ts b/packages/api/src/moderation/subjects/user-list.ts index 39a0daf85ee..f5ed15177d9 100644 --- a/packages/api/src/moderation/subjects/user-list.ts +++ b/packages/api/src/moderation/subjects/user-list.ts @@ -1,3 +1,4 @@ +import { AtUri } from '@atproto/syntax' import { AppBskyActorDefs } from '../../client/index' import { ModerationDecision } from '../decision' import { ModerationSubjectUserList, ModerationOpts } from '../types' @@ -8,12 +9,36 @@ export function decideUserList( subject: ModerationSubjectUserList, opts: ModerationOpts, ): ModerationDecision { - // TODO handle labels applied on the list itself - 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(account, profile) + 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 } From 2ff5fd066163de274b136b06f4632d087cb80610 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 12 Mar 2024 19:21:17 -0700 Subject: [PATCH 36/41] Add another test label --- packages/dev-env/src/mock/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 52ee009febc..ef98bc7f599 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -781,6 +781,12 @@ export async function generateMockSetup(env: TestNetwork) { 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: '', From cd55637a07a4833ff437c7288e07dee6fd77569d Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 22:32:11 -0500 Subject: [PATCH 37/41] fix labeler in dev-env agents --- packages/bsky/tests/views/timeline.test.ts | 9 +++++++-- packages/dev-env/src/bsky.ts | 6 +++--- packages/dev-env/src/const.ts | 1 + packages/dev-env/src/index.ts | 1 + packages/dev-env/src/mock/index.ts | 4 ++-- packages/dev-env/src/network.ts | 3 ++- packages/dev-env/src/pds.ts | 4 ++-- packages/dev-env/src/seed/basic.ts | 3 ++- packages/ozone/tests/query-labels.test.ts | 14 +++++++------- packages/ozone/tests/sequencer.test.ts | 4 ++-- packages/pds/tests/seeds/basic.ts | 4 ++-- 11 files changed, 31 insertions(+), 22 deletions(-) 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 ef98bc7f599..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' @@ -818,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/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/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() } From 184bca97374c9354cd14413ab55c66041777b7a5 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 22:43:36 -0500 Subject: [PATCH 38/41] fix label hydration test --- packages/bsky/tests/label-hydration.test.ts | 23 ++++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 }, { From 7e61ee7e056349fbac7de593ade4af27c3a69e0a Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 22:45:02 -0500 Subject: [PATCH 39/41] fix lint error --- packages/api/src/moderation/mutewords.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api/src/moderation/mutewords.ts b/packages/api/src/moderation/mutewords.ts index de8ad9cb163..a4df492382c 100644 --- a/packages/api/src/moderation/mutewords.ts +++ b/packages/api/src/moderation/mutewords.ts @@ -3,6 +3,8 @@ 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, } From 3f879c841fe3ddba90550ce6137cd44459bd2e18 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 22:59:18 -0500 Subject: [PATCH 40/41] fix agent test --- packages/api/package.json | 3 ++- packages/api/tests/agent.test.ts | 20 ++++++++++++-------- pnpm-lock.yaml | 11 +++++++---- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index 0dc1a3339b7..8a9e7b0a760 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -41,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/tests/agent.test.ts b/packages/api/tests/agent.test.ts index b4ad10802db..f618c0a5bc9 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -1,4 +1,5 @@ import assert from 'assert' +import getPort from 'get-port' import { defaultFetchHandler } from '@atproto/xrpc' import { AtpAgent, @@ -28,7 +29,7 @@ describe('agent', () => { }) it('clones correctly', () => { - const persistSession = (evt: AtpSessionEvent, sess?: AtpSessionData) => {} + const persistSession = (_evt: AtpSessionEvent, _sess?: AtpSessionData) => {} const agent = new AtpAgent({ service: network.pds.url, persistSession }) const agent2 = agent.clone() expect(agent2 instanceof AtpAgent).toBeTruthy() @@ -492,9 +493,10 @@ describe('agent', () => { describe('App labelers header', () => { it('adds the labelers header as expected', async () => { - const server = await createHeaderEchoServer(15991) - const agent = new AtpAgent({ service: 'http://localhost:15991' }) - const agent2 = new AtpAgent({ service: 'http://localhost:15991' }) + 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( @@ -518,8 +520,9 @@ describe('agent', () => { 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() @@ -539,8 +542,9 @@ describe('agent', () => { describe('configureProxyHeader', () => { it('adds the proxy header as expected', async () => { - const server = await createHeaderEchoServer(15992) - const agent = new AtpAgent({ service: 'http://localhost:15992' }) + 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() 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 From a11f22a5ca844b5df8f207f94f8552d55cd0fe43 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Mar 2024 23:03:59 -0500 Subject: [PATCH 41/41] fix takedown labels test --- packages/bsky/tests/views/takedown-labels.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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) }) })