Feature - 3rd party labelers ()

* adding some basic views

* feed hydration, add blocks to post hydration

* pass over notification hydration

* tidy

* merge

* implement getProfile

* hydrate post aggregation and viewer state

* fix

* fix codegen

* get some tests passing!

* add takedowns & some like bugfixing

* all profile tests passing!

* likes test

* follow endpoints using data plane

* reorg follow block rules

* reposts

* post views!

* implement getList w/ dataplane caveat

* adjust dataplane getListMembers to return listitem uris

* implement getListMutes and -Blocks w/ dataplane

* suggestions

* timeline

* misc view fixes

* view fixes for mutes, self-mute/block

* author feed

* feed gen routes

* tidy

* misc block/mute fixes

* list feed & actor likes

* implement getLists, fix some empty cursors

* implement getMutes, empty profile description fix

* implement getBlocks, block application fix

* implement getSuggestedFollowsByActor, needs some fixes

* feed generation

* search routes

* threads

* tidy

* fix some snaps

* fix getSuggestedFollowsByActor

* implement listNotifications

* implement getUnreadCount w/ dataplane

* implement notifications.updateSeen w/ dataplane

* 3rd party blocking tests

* blocked profile viewer

* add search mocks

* refactor getFeed

* createPipeline -> createPipelineNew

* basic replygating functionality on dataplane w/o filtering violating replies

* hack threadgates into dataplane, apply gates

* deterministic thread orders in dataplane

* misc cleanup around dataplane

* upgrade typescript to v5.3

* update typescript linter deps

* sync bsky proto, codegen

* update dataplane, sync with bsky proto updates

* remove indexer, ingester, daemon, moderation services from appview

* convert more bsky internals to dataplane, remove custom feedgens, implement mute/unmuting in mock dataplane

* remove bsky services. db and indexing logic into mock dataplane.

* remove tests not needed by appview v2, misc reorg

* add basic in-mem repo subscription to dataplane mock

* fix dev-env, bsky tests, bsky build

* cull bsky service entrypoint

* add bsky service readme

* build

* tidy

* tidy, fix pds proxy tests

* fix

* fix bsky entrypoint deps

* support http2 grpc client

* build

* fix dataplane bad tls config/default

* support multiple dataplane urls, retry when unavailable

* build

* tidy/fix

* move dataplane mock tests into their own dir

* cover label hydration through timeline test

* bring back labels in appview tests

* remove unused db primary/replica/coordinator from bsky dataplane

* bsky proto add cids to contracts, buf codegen

* sync-up bsky data-plane w/ codegen updates

* start using dataplane interaction endpoints

* add file

* avoid overfetching from dataplane, plumb feed items and cids

* pass refs through for post viewer state

* Lexicons: Add labeler prefs, labeler declaration, and get labeler routes

* Add labelerViewBasic and update embed views

* Fix typo

* switch list feeds to use feed item in dataplane

* handle not found err on get-thread dataplane call

* support use of search service rather than dataplane methods

* mark some appview v2 todos

* tidy

* still use dataplane on search endpoints when search service is not configured

* fix pds test

* Switch to labelerViewDetailed

* Move label and report values to refs

* Add getActorLabelers

* lint

* fix up bsky tests & snaps

* small diff to open pr

* rm new line

* codegen schemas

* tidy migrations

* table + indexing

* protos

* rename lexicons

* views, hydration + rename lexicons

* rest of routes

* data plane routes

* parse labelers from req

* fix appview-v2 docker build

* Support label issuer tied to appview v2 ()

support label issuer tied to appview

* hydrate context

* tidy header logic

* integrating into more routes

* more routes

* wrap up rest

* add mock labeler

* rework labelerlexicons

* tidy lexs

* codegen new lexicons

* integrate lexicon rework

* add proxy logic

* forward labeler headers through pds

* tweak label header parsing

* remove did from scheams

* update indexing for lexs

* tests for mod service views

* label hydration test

* Add 'associated' to profileViewDetailed

* Rename labelers to mods in preferences

* Change uri to did in mod preferences

* couple more

* syntax tweaks

* integrate updated lexicons

* update view snap

* handle mod service embeds

* tidy

* fix mock

* lint

* base default labels of config var

* fix label hydration

* Appview v2: handle empty cursor on list notifications ()

handle empty cursor on appview listnotifs

* Update appview v2 to use author feed enum ()

* update bsky protos with author feed enum, misc feed item changes

* support new author feed enums in dataplane

* fix build

* Appview v2: utilize sorted-at field in bsky protos ()

utilize new sorted-at field in bsky protos

* remove all dataplane usage of GetLikeCounts, switch to GetInteractionCounts

* Appview v2, sync w/ changes to protos ()

* sync bsky protos

* sync-up bsky implementation w/ proto changes

* Appview v2 initial implementation for getPopularFeedGenerators ()

add an initial implementation for getPopularFeedGenerators on appview v2

* merge

* fixes

* fix feed tests

* fix bsync mock

* format

* remove unused config

* fix lockfile

* another lockfile fix

* fix duplicate type

* fix dupplicate test

* Appview v2 handling clearly bad cursors ()

* make mock dataplane cursors different from v1 cursors

* fail open on clearly bad appview cursors

* fix pds appview proxy snaps

* Appview v2 no notifs seen behavior ()

* alter behavior for presenting notifications w/ no last-seen time

* fix pds proxy tests

* Appview v2 dataplane retries based on client host ()

choose dataplane client for retries based on host when possible/relevant

* don't apply negated labels

* display suspensions on actor profile in appview v2

* Appview v2 use dataplane for identity lookups ()

* update bsky proto w/ identity methods

* setup identity endpoints on mock dataplane

* move from idresolver to dataplane for identity lookups on appview

* tidy

* Appview v2: apply safe takedown refs to records, actors ()

apply safe takedown refs to records, actors

* Fix timing on appview v2 repo rev header ()

fix timing on appview repo rev

* fix post thread responses

* Appview v2 don't apply 3p self blocks ()

do not apply 3p self-blocks

* Appview v2 search for feed generators ()

* add protos for feedgen search

* support feed search on getPopularFeedGenerators

* Appview v2 config tidy ()

* remove mod and triage roles from appview

* rename cdn and search config

* remove custom feed harness from appview v2

* Appview v2: don't apply missing modlists ()

* dont apply missing mod lists

* update mock dataplane

* Update packages/bsky/src/hydration/hydrator.ts

Co-authored-by: devin ivy <devinivy@gmail.com>

* refactor & document a bit better

* fix up other routes

---------

Co-authored-by: devin ivy <devinivy@gmail.com>

* Appview v2 enforce post thread root boundary ()

* enforce post thread root boundary

* test thread root boundary

* Appview v2 fix admin environment variable ()

fix admin env in appview v2

* Remove re-pagination from getSuggestions ()

* remove re-pagination from getSuggestions

* fix test

* Adjust wording for account suspension ()

adjust wording for account suspension

* Appview v2: fix not-found and blocked uris in threads ()

* fix uris of not-found and blocked posts in threads

* update snaps

*  Show author feed of takendown author to admins only ()

* fold in cid, auth, tracing, node version changes

* remove dead config from bsky service entrypoint

* build

* remove ozone test codepaths for appview v2

* tidy, docs fix

* fix test

* add additional user counts

* add associated data to profiles

* update snaps

* update to is_mod_service

* format

* tidy

* 3p labeler sdk updates ()

* Update sdk to support 3p labeler preferences

* Stick with intolerance instead of hate for the label group id

* wip expand labels and label groups

* Output moderationOpts (computed) and modsPref (unaltered)

* Add tests for enabling/disabling mod services

* Add atproto-labelers header config

* Expand labels and label groups in definitions

* Fix tests

* Tweaks to labels

* Remove label descriptions and improve output types on labels and label groups

* Add mocker to exported API

* Improve types of label and label group definitions

* Rework moderation prefs to continue using global labelgroup settings and only disable label groups per moderator

* Simplify encoding of the label preferences in definitions

* Add target constraints to labels

* Refactor the moderation sdk to derive more behaviors from the definition files

* Small cleanup

* Add hiding tool to modsdk

* Track filter causes

* Make mute state an alert

* Fix: dont blur profileview for blocks

* Prioritize causes by severity

* Add moderateNotification() and drop quote post moderation code

* Add mocker functions for notifications

* Improve mock data

* Lexicon: Add custom label definitions and remove modservice descriptions

* Lexicon: Update moderation prefs

* SDK updates: remove label groups, reduce builtin labels, update mod-preference apis

* Lexicon: Update global labels with new reduced set

* Lexicon: Remove moderation.getService and add detailed option to getServices

* Lexicons: add severity=none to custom label value defs

* Implement custom label-value definition tooling

* All custom labels are no-self

* Backend impl for labeler lexicon updates ()

* codegen

* clean up impl

* fix up tests

* Lexicon: modservice -> labeler

* Remove x- prefix behavior; add label value syntax rules; add custom label precedence rules

* Lexicon: Remove the ability to choose a defaultSetting from custom labels

* Rework test suites

* Give behaviors to all labels regardless of target

* sync up backend with lex changes

* fix labelers in dev-env agent

* lint protos

* update protos & views

* small bugfix & update tests

* tweak protos

* fix build issue from merge

---------

Co-authored-by: Devin Ivy <devinivy@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Foysal Ahamed <foysal@blueskyweb.xyz>
This commit is contained in:
Daniel Holmgren 2024-03-06 17:56:34 -06:00 committed by GitHub
parent bae2ce3809
commit 3988543258
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
195 changed files with 8585 additions and 9870 deletions
lexicons
packages
api
bsky

@ -67,6 +67,10 @@
"followersCount": { "type": "integer" },
"followsCount": { "type": "integer" },
"postsCount": { "type": "integer" },
"associated": {
"type": "ref",
"ref": "#profileAssociated"
},
"indexedAt": { "type": "string", "format": "datetime" },
"viewer": { "type": "ref", "ref": "#viewerState" },
"labels": {
@ -75,6 +79,14 @@
}
}
},
"profileAssociated": {
"type": "object",
"properties": {
"lists": { "type": "integer" },
"feedgens": { "type": "integer" },
"labeler": { "type": "boolean" }
}
},
"viewerState": {
"type": "object",
"description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.",
@ -122,10 +134,15 @@
"type": "object",
"required": ["label", "visibility"],
"properties": {
"labelerDid": {
"type": "string",
"description": "Which labeler does this preference apply to? If undefined, applies globally.",
"format": "did"
},
"label": { "type": "string" },
"visibility": {
"type": "string",
"knownValues": ["show", "warn", "hide"]
"knownValues": ["ignore", "show", "warn", "hide"]
}
}
},
@ -270,6 +287,29 @@
"description": "A list of URIs of posts the account owner has hidden."
}
}
},
"modsPref": {
"type": "object",
"required": ["mods"],
"properties": {
"mods": {
"type": "array",
"items": {
"type": "ref",
"ref": "#modPrefItem"
}
}
}
},
"modPrefItem": {
"type": "object",
"required": ["did"],
"properties": {
"did": {
"type": "string",
"format": "did"
}
}
}
}
}

@ -21,7 +21,8 @@
"#viewNotFound",
"#viewBlocked",
"app.bsky.feed.defs#generatorView",
"app.bsky.graph.defs#listView"
"app.bsky.graph.defs#listView",
"app.bsky.labeler.defs#labelerView"
]
}
}

@ -0,0 +1,70 @@
{
"lexicon": 1,
"id": "app.bsky.labeler.defs",
"defs": {
"labelerView": {
"type": "object",
"required": ["uri", "cid", "creator", "indexedAt"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"cid": { "type": "string", "format": "cid" },
"creator": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" },
"likeCount": { "type": "integer", "minimum": 0 },
"viewer": { "type": "ref", "ref": "#labelerViewerState" },
"indexedAt": { "type": "string", "format": "datetime" },
"labels": {
"type": "array",
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
}
}
},
"labelerViewDetailed": {
"type": "object",
"required": ["uri", "cid", "creator", "policies", "indexedAt"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"cid": { "type": "string", "format": "cid" },
"creator": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" },
"policies": {
"type": "ref",
"ref": "app.bsky.labeler.defs#labelerPolicies"
},
"likeCount": { "type": "integer", "minimum": 0 },
"viewer": { "type": "ref", "ref": "#labelerViewerState" },
"indexedAt": { "type": "string", "format": "datetime" },
"labels": {
"type": "array",
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
}
}
},
"labelerViewerState": {
"type": "object",
"properties": {
"like": { "type": "string", "format": "at-uri" }
}
},
"labelerPolicies": {
"type": "object",
"required": ["labelValues"],
"properties": {
"labelValues": {
"type": "array",
"description": "The label values which this labeler publishes. May include global or custom labels.",
"items": {
"type": "ref",
"ref": "com.atproto.label.defs#labelValue"
}
},
"labelValueDefinitions": {
"type": "array",
"description": "Label values created by this labeler and scoped exclusively to it. Labels defined here will override global label definitions for this labeler.",
"items": {
"type": "ref",
"ref": "com.atproto.label.defs#labelValueDefinition"
}
}
}
}
}
}

@ -0,0 +1,43 @@
{
"lexicon": 1,
"id": "app.bsky.labeler.getServices",
"defs": {
"main": {
"type": "query",
"description": "Get information about a list of labeler services.",
"parameters": {
"type": "params",
"required": ["dids"],
"properties": {
"dids": {
"type": "array",
"items": { "type": "string", "format": "did" }
},
"detailed": {
"type": "boolean",
"default": false
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["views"],
"properties": {
"views": {
"type": "array",
"items": {
"type": "union",
"refs": [
"app.bsky.labeler.defs#labelerView",
"app.bsky.labeler.defs#labelerViewDetailed"
]
}
}
}
}
}
}
}
}

@ -0,0 +1,26 @@
{
"lexicon": 1,
"id": "app.bsky.labeler.service",
"defs": {
"main": {
"type": "record",
"description": "A declaration of the existence of labeler service.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["policies", "createdAt"],
"properties": {
"policies": {
"type": "ref",
"ref": "app.bsky.labeler.defs#labelerPolicies"
},
"labels": {
"type": "union",
"refs": ["com.atproto.label.defs#selfLabels"]
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

@ -61,6 +61,73 @@
"description": "The short string name of the value or type of this label."
}
}
},
"labelValueDefinition": {
"type": "object",
"description": "Declares a label value and its expected interpertations and behaviors.",
"required": ["identifier", "severity", "blurs", "locales"],
"properties": {
"identifier": {
"type": "string",
"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
"maxLength": 100,
"maxGraphemes": 100
},
"severity": {
"type": "string",
"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
"knownValues": ["inform", "alert", "none"]
},
"blurs": {
"type": "string",
"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"]
},
"locales": {
"type": "array",
"items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
}
}
},
"labelValueDefinitionStrings": {
"type": "object",
"description": "Strings which describe the label in the UI, localized into a specific language.",
"required": ["lang", "name", "description"],
"properties": {
"lang": {
"type": "string",
"description": "The code of the language these strings are written in.",
"format": "language"
},
"name": {
"type": "string",
"description": "A short human-readable name for the label.",
"maxGraphemes": 64,
"maxLength": 640
},
"description": {
"type": "string",
"description": "A longer description of what the label means and why it might be applied.",
"maxGraphemes": 10000,
"maxLength": 100000
}
}
},
"labelValue": {
"type": "string",
"knownValues": [
"!hide",
"!no-promote",
"!warn",
"!no-unauthenticated",
"dmca-violation",
"doxxing",
"porn",
"sexual",
"nudity",
"nsfl",
"gore"
]
}
}
}

@ -1,224 +1,226 @@
[
{
"id": "system",
"identifier": "!hide",
"configurable": false,
"labels": [
{
"id": "!hide",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
"defaultSetting": "hide",
"flags": ["no-override", "no-self"],
"severity": "alert",
"blurs": "content",
"behaviors": {
"account": {
"profileList": "blur",
"profileView": "blur",
"avatar": "blur",
"banner": "blur",
"displayName": "blur",
"contentList": "blur",
"contentView": "blur"
},
{
"id": "!no-promote",
"preferences": ["hide"],
"flags": [],
"onwarn": null
"profile": {
"avatar": "blur",
"banner": "blur",
"displayName": "blur"
},
{
"id": "!warn",
"preferences": ["warn"],
"flags": [],
"onwarn": "blur"
},
{
"id": "!no-unauthenticated",
"preferences": ["hide"],
"flags": ["no-override", "unauthed"],
"onwarn": "blur"
"content": {
"contentList": "blur",
"contentView": "blur"
}
]
}
},
{
"id": "legal",
"identifier": "!no-promote",
"configurable": false,
"labels": [
{
"id": "dmca-violation",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
},
{
"id": "doxxing",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
}
]
"defaultSetting": "hide",
"flags": ["no-self"],
"severity": "none",
"blurs": "none",
"behaviors": {}
},
{
"id": "sexual",
"configurable": true,
"labels": [
{
"id": "porn",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"identifier": "!warn",
"configurable": false,
"defaultSetting": "warn",
"flags": ["no-self"],
"severity": "none",
"blurs": "content",
"behaviors": {
"account": {
"profileList": "blur",
"profileView": "blur",
"avatar": "blur",
"banner": "blur",
"contentList": "blur",
"contentView": "blur"
},
{
"id": "sexual",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"profile": {
"avatar": "blur",
"banner": "blur",
"displayName": "blur"
},
{
"id": "nudity",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"content": {
"contentList": "blur",
"contentView": "blur"
}
]
}
},
{
"id": "violence",
"configurable": true,
"labels": [
{
"id": "nsfl",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"identifier": "!no-unauthenticated",
"configurable": false,
"defaultSetting": "hide",
"flags": ["no-override", "unauthed"],
"severity": "none",
"blurs": "content",
"behaviors": {
"account": {
"profileList": "blur",
"profileView": "blur",
"avatar": "blur",
"banner": "blur",
"displayName": "blur",
"contentList": "blur",
"contentView": "blur"
},
{
"id": "corpse",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"profile": {
"avatar": "blur",
"banner": "blur",
"displayName": "blur"
},
{
"id": "gore",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "torture",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur"
},
{
"id": "self-harm",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
"content": {
"contentList": "blur",
"contentView": "blur"
}
]
}
},
{
"id": "intolerance",
"configurable": true,
"labels": [
{
"id": "intolerant-race",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"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"
},
{
"id": "intolerant-gender",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"profile": {
"profileList": "blur",
"profileView": "blur"
},
{
"id": "intolerant-sexual-orientation",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant-religion",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "icon-intolerant",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur-media"
"content": {
"contentList": "blur",
"contentView": "blur"
}
]
}
},
{
"id": "rude",
"configurable": true,
"labels": [
{
"id": "threat",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "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"
}
]
}
},
{
"id": "curation",
"identifier": "porn",
"configurable": true,
"labels": [
{
"id": "spoiler",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"defaultSetting": "hide",
"flags": ["adult"],
"severity": "none",
"blurs": "media",
"behaviors": {
"account": {
"avatar": "blur",
"banner": "blur"
},
"profile": {
"avatar": "blur",
"banner": "blur"
},
"content": {
"contentMedia": "blur"
}
]
}
},
{
"id": "spam",
"identifier": "sexual",
"configurable": true,
"labels": [
{
"id": "spam",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"defaultSetting": "warn",
"flags": ["adult"],
"severity": "none",
"blurs": "media",
"behaviors": {
"account": {
"avatar": "blur",
"banner": "blur"
},
"profile": {
"avatar": "blur",
"banner": "blur"
},
"content": {
"contentMedia": "blur"
}
]
}
},
{
"id": "misinfo",
"identifier": "nudity",
"configurable": true,
"labels": [
{
"id": "account-security",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"defaultSetting": "warn",
"flags": ["adult"],
"severity": "none",
"blurs": "media",
"behaviors": {
"account": {
"avatar": "blur",
"banner": "blur"
},
{
"id": "net-abuse",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
"profile": {
"avatar": "blur",
"banner": "blur"
},
{
"id": "impersonation",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "scam",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "misleading",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
"content": {
"contentMedia": "blur"
}
]
}
},
{
"identifier": "gore",
"flags": ["adult"],
"configurable": true,
"defaultSetting": "warn",
"severity": "none",
"blurs": "media",
"behaviors": {
"account": {
"avatar": "blur",
"banner": "blur"
},
"profile": {
"avatar": "blur",
"banner": "blur"
},
"content": {
"contentMedia": "blur"
}
}
}
]

@ -1,38 +0,0 @@
{
"system": {
"name": "System",
"description": "Moderator overrides for special cases."
},
"legal": {
"name": "Legal",
"description": "Content removed for legal reasons."
},
"sexual": {
"name": "Adult Content",
"description": "Content which is sexual in nature."
},
"violence": {
"name": "Violence",
"description": "Content which is violent or deeply disturbing."
},
"intolerance": {
"name": "Intolerance",
"description": "Content or behavior which is hateful or intolerant toward a group of people."
},
"rude": {
"name": "Rude",
"description": "Behavior which is rude toward other users."
},
"curation": {
"name": "Curational",
"description": "Subjective moderation geared towards curating a more positive environment."
},
"spam": {
"name": "Spam",
"description": "Content which doesn't add to the conversation."
},
"misinfo": {
"name": "Misinformation",
"description": "Content which misleads or defrauds users."
}
}

@ -1,394 +0,0 @@
{
"!hide": {
"settings": {
"name": "Moderator Hide",
"description": "Moderator has chosen to hide the content."
},
"account": {
"name": "Content Blocked",
"description": "This account has been hidden by the moderators."
},
"content": {
"name": "Content Blocked",
"description": "This content has been hidden by the moderators."
}
},
"!no-promote": {
"settings": {
"name": "Moderator Filter",
"description": "Moderator has chosen to filter the content from feeds."
},
"account": {
"name": "N/A",
"description": "N/A"
},
"content": {
"name": "N/A",
"description": "N/A"
}
},
"!warn": {
"settings": {
"name": "Moderator Warn",
"description": "Moderator has chosen to set a general warning on the content."
},
"account": {
"name": "Content Warning",
"description": "This account has received a general warning from moderators."
},
"content": {
"name": "Content Warning",
"description": "This content has received a general warning from moderators."
}
},
"!no-unauthenticated": {
"settings": {
"name": "Sign-in Required",
"description": "This user has requested that their account only be shown to signed-in users."
},
"account": {
"name": "Sign-in Required",
"description": "This user has requested that their account only be shown to signed-in users."
},
"content": {
"name": "Sign-in Required",
"description": "This user has requested that their content only be shown to signed-in users."
}
},
"dmca-violation": {
"settings": {
"name": "Copyright Violation",
"description": "The content has received a DMCA takedown request."
},
"account": {
"name": "Copyright Violation",
"description": "This account has received a DMCA takedown request. It will be restored if the concerns can be resolved."
},
"content": {
"name": "Copyright Violation",
"description": "This content has received a DMCA takedown request. It will be restored if the concerns can be resolved."
}
},
"doxxing": {
"settings": {
"name": "Doxxing",
"description": "Information that reveals private information about someone which has been shared without the consent of the subject."
},
"account": {
"name": "Doxxing",
"description": "This account has been reported to publish private information about someone without their consent. This report is currently under review."
},
"content": {
"name": "Doxxing",
"description": "This content has been reported to include private information about someone without their consent."
}
},
"porn": {
"settings": {
"name": "Pornography",
"description": "Images of full-frontal nudity (genitalia) in any sexualized context, or explicit sexual activity (meaning contact with genitalia or breasts) even if partially covered. Includes graphic sexual cartoons (often jokes/memes)."
},
"account": {
"name": "Adult Content",
"description": "This account contains imagery of full-frontal nudity or explicit sexual activity."
},
"content": {
"name": "Adult Content",
"description": "This content contains imagery of full-frontal nudity or explicit sexual activity."
}
},
"sexual": {
"settings": {
"name": "Sexually Suggestive",
"description": "Content that does not meet the level of \"pornography\", but is still sexual. Some common examples have been selfies and \"hornyposting\" with underwear on, or partially naked (naked but covered, eg with hands or from side perspective). Sheer/see-through nipples may end up in this category."
},
"account": {
"name": "Suggestive Content",
"description": "This account contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress."
},
"content": {
"name": "Suggestive Content",
"description": "This content contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress."
}
},
"nudity": {
"settings": {
"name": "Nudity",
"description": "Nudity which is not sexual, or that is primarily \"artistic\" in nature. For example: breastfeeding; classic art paintings and sculptures; newspaper images with some nudity; fashion modeling. \"Erotic photography\" is likely to end up in sexual or porn."
},
"account": {
"name": "Adult Content",
"description": "This account contains imagery which portrays nudity in a non-sexual or artistic setting."
},
"content": {
"name": "Adult Content",
"description": "This content contains imagery which portrays nudity in a non-sexual or artistic setting."
}
},
"nsfl": {
"settings": {
"name": "NSFL",
"description": "\"Not Suitable For Life.\" This includes graphic images like the infamous \"goatse\" (don't look it up)."
},
"account": {
"name": "Graphic Imagery (NSFL)",
"description": "This account contains graphic images which are often referred to as \"Not Suitable For Life.\""
},
"content": {
"name": "Graphic Imagery (NSFL)",
"description": "This content contains graphic images which are often referred to as \"Not Suitable For Life.\""
}
},
"corpse": {
"settings": {
"name": "Corpse",
"description": "Visual image of a dead human body in any context. Includes war images, hanging, funeral caskets. Does not include all figurative cases (cartoons), but can include realistic figurative images or renderings."
},
"account": {
"name": "Graphic Imagery (Corpse)",
"description": "This account contains images of a dead human body in any context. Includes war images, hanging, funeral caskets."
},
"content": {
"name": "Graphic Imagery (Corpse)",
"description": "This content contains images of a dead human body in any context. Includes war images, hanging, funeral caskets."
}
},
"gore": {
"settings": {
"name": "Gore",
"description": "Intended for shocking images, typically involving blood or visible wounds."
},
"account": {
"name": "Graphic Imagery (Gore)",
"description": "This account contains shocking images involving blood or visible wounds."
},
"content": {
"name": "Graphic Imagery (Gore)",
"description": "This content contains shocking images involving blood or visible wounds."
}
},
"torture": {
"settings": {
"name": "Torture",
"description": "Depictions of torture of a human or animal (animal cruelty)."
},
"account": {
"name": "Graphic Imagery (Torture)",
"description": "This account contains depictions of torture of a human or animal."
},
"content": {
"name": "Graphic Imagery (Torture)",
"description": "This content contains depictions of torture of a human or animal."
}
},
"self-harm": {
"settings": {
"name": "Self-Harm",
"description": "A visual depiction (photo or figurative) of cutting, suicide, or similar."
},
"account": {
"name": "Graphic Imagery (Self-Harm)",
"description": "This account includes depictions of cutting, suicide, or other forms of self-harm."
},
"content": {
"name": "Graphic Imagery (Self-Harm)",
"description": "This content includes depictions of cutting, suicide, or other forms of self-harm."
}
},
"intolerant-race": {
"settings": {
"name": "Racial Intolerance",
"description": "Hateful or intolerant content related to race."
},
"account": {
"name": "Intolerance (Racial)",
"description": "This account includes hateful or intolerant content related to race."
},
"content": {
"name": "Intolerance (Racial)",
"description": "This content includes hateful or intolerant views related to race."
}
},
"intolerant-gender": {
"settings": {
"name": "Gender Intolerance",
"description": "Hateful or intolerant content related to gender or gender identity."
},
"account": {
"name": "Intolerance (Gender)",
"description": "This account includes hateful or intolerant content related to gender or gender identity."
},
"content": {
"name": "Intolerance (Gender)",
"description": "This content includes hateful or intolerant views related to gender or gender identity."
}
},
"intolerant-sexual-orientation": {
"settings": {
"name": "Sexual Orientation Intolerance",
"description": "Hateful or intolerant content related to sexual preferences."
},
"account": {
"name": "Intolerance (Orientation)",
"description": "This account includes hateful or intolerant content related to sexual preferences."
},
"content": {
"name": "Intolerance (Orientation)",
"description": "This content includes hateful or intolerant views related to sexual preferences."
}
},
"intolerant-religion": {
"settings": {
"name": "Religious Intolerance",
"description": "Hateful or intolerant content related to religious views or practices."
},
"account": {
"name": "Intolerance (Religious)",
"description": "This account includes hateful or intolerant content related to religious views or practices."
},
"content": {
"name": "Intolerance (Religious)",
"description": "This content includes hateful or intolerant views related to religious views or practices."
}
},
"intolerant": {
"settings": {
"name": "Intolerance",
"description": "A catchall for hateful or intolerant content which is not covered elsewhere."
},
"account": {
"name": "Intolerance",
"description": "This account includes hateful or intolerant content."
},
"content": {
"name": "Intolerance",
"description": "This content includes hateful or intolerant views."
}
},
"icon-intolerant": {
"settings": {
"name": "Intolerant Iconography",
"description": "Visual imagery associated with a hate group, such as the KKK or Nazi, in any context (supportive, critical, documentary, etc)."
},
"account": {
"name": "Intolerant Iconography",
"description": "This account includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes."
},
"content": {
"name": "Intolerant Iconography",
"description": "This content includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes."
}
},
"threat": {
"settings": {
"name": "Threats",
"description": "Statements or imagery published with the intent to threaten, intimidate, or harm."
},
"account": {
"name": "Threats",
"description": "The moderators believe this account has published statements or imagery with the intent to threaten, intimidate, or harm others."
},
"content": {
"name": "Threats",
"description": "The moderators believe this content was published with the intent to threaten, intimidate, or harm others."
}
},
"spoiler": {
"settings": {
"name": "Spoiler",
"description": "Discussion about film, TV, etc which gives away plot points."
},
"account": {
"name": "Spoiler Warning",
"description": "This account contains discussion about film, TV, etc which gives away plot points."
},
"content": {
"name": "Spoiler Warning",
"description": "This content contains discussion about film, TV, etc which gives away plot points."
}
},
"spam": {
"settings": {
"name": "Spam",
"description": "Repeat, low-quality messages which are clearly not designed to add to a conversation or space."
},
"account": {
"name": "Spam",
"description": "This account publishes repeat, low-quality messages which are clearly not designed to add to a conversation or space."
},
"content": {
"name": "Spam",
"description": "This content is a part of repeat, low-quality messages which are clearly not designed to add to a conversation or space."
}
},
"account-security": {
"settings": {
"name": "Security Concerns",
"description": "Content designed to hijack user accounts such as a phishing attack."
},
"account": {
"name": "Security Warning",
"description": "This account has published content designed to hijack user accounts such as a phishing attack."
},
"content": {
"name": "Security Warning",
"description": "This content is designed to hijack user accounts such as a phishing attack."
}
},
"net-abuse": {
"settings": {
"name": "Network Attacks",
"description": "Content designed to attack network systems such as denial-of-service attacks."
},
"account": {
"name": "Network Attack Warning",
"description": "This account has published content designed to attack network systems such as denial-of-service attacks."
},
"content": {
"name": "Network Attack Warning",
"description": "This content is designed to attack network systems such as denial-of-service attacks."
}
},
"impersonation": {
"settings": {
"name": "Impersonation",
"description": "Accounts which falsely assert some identity."
},
"account": {
"name": "Impersonation Warning",
"description": "The moderators believe this account is lying about their identity."
},
"content": {
"name": "Impersonation Warning",
"description": "The moderators believe this account is lying about their identity."
}
},
"scam": {
"settings": {
"name": "Scam",
"description": "Fraudulent content."
},
"account": {
"name": "Scam Warning",
"description": "The moderators believe this account publishes fraudulent content."
},
"content": {
"name": "Scam Warning",
"description": "The moderators believe this is fraudulent content."
}
},
"misleading": {
"settings": {
"name": "Misleading",
"description": "Accounts which share misleading information."
},
"account": {
"name": "Misleading",
"description": "The moderators believe this account is spreading misleading information."
},
"content": {
"name": "Misleading",
"description": "The moderators believe this account is spreading misleading information."
}
}
}

@ -1,38 +0,0 @@
{
"system": {
"name": "System",
"description": "Moderator overrides for special cases."
},
"legal": {
"name": "Legal",
"description": "Content removed for legal reasons."
},
"sexual": {
"name": "Adult Content",
"description": "Content which is sexual in nature."
},
"violence": {
"name": "Violence",
"description": "Content which is violent or deeply disturbing."
},
"intolerance": {
"name": "Intolerance",
"description": "Content or behavior which is hateful or intolerant toward a group of people."
},
"rude": {
"name": "Rude",
"description": "Behavior which is rude toward other users."
},
"curation": {
"name": "Curational",
"description": "Subjective moderation geared towards curating a more positive environment."
},
"spam": {
"name": "Spam",
"description": "Content which doesn't add to the conversation."
},
"misinfo": {
"name": "Misinformation",
"description": "Content which misleads or defrauds users."
}
}

@ -1,632 +0,0 @@
{
"!hide": {
"settings": {
"name": "Moderator Hide",
"description": "Moderator has chosen to hide the content."
},
"account": {
"name": "Content Blocked",
"description": "This account has been hidden by the moderators."
},
"content": {
"name": "Content Blocked",
"description": "This content has been hidden by the moderators."
}
},
"!no-promote": {
"settings": {
"name": "Moderator Filter",
"description": "Moderator has chosen to filter the content from feeds."
},
"account": {
"name": "N/A",
"description": "N/A"
},
"content": {
"name": "N/A",
"description": "N/A"
}
},
"!warn": {
"settings": {
"name": "Moderator Warn",
"description": "Moderator has chosen to set a general warning on the content."
},
"account": {
"name": "Content Warning",
"description": "This account has received a general warning from moderators."
},
"content": {
"name": "Content Warning",
"description": "This content has received a general warning from moderators."
}
},
"nudity-nonconsensual": {
"settings": {
"name": "Nonconsensual Nudity",
"description": "Nudity or sexual material which has been identified as being shared without the consent of the subjects."
},
"account": {
"name": "Nonconsensual Nudity",
"description": "This account has triggered the Nonconsensual Nudity Review systems. This may be in error, so please do not jump to conclusions while the account is under review. This warning will be lifted if the review was triggered incorrectly. Otherwise, the account will be removed from the network."
},
"content": {
"name": "Nonconsensual Nudity",
"description": "This content has triggered the Nonconsensual Nudity Review systems. This may be in error, so please do not jump to conclusions while the account is under review. This warning will be lifted if the review was triggered incorrectly. Otherwise, the account will be removed from the network."
}
},
"dmca-violation": {
"settings": {
"name": "Copyright Violation",
"description": "The content has received a DMCA takedown request."
},
"account": {
"name": "Copyright Violation",
"description": "This account has received a DMCA takedown request. It will be restored if the concerns can be resolved."
},
"content": {
"name": "Copyright Violation",
"description": "This content has received a DMCA takedown request. It will be restored if the concerns can be resolved."
}
},
"doxxing": {
"settings": {
"name": "Doxxing",
"description": "Information that reveals private information about someone which has been shared without the consent of the subject."
},
"account": {
"name": "Doxxing",
"description": "This account has been reported to publish private information about someone without their consent. This report is currently under review."
},
"content": {
"name": "Doxxing",
"description": "This content has been reported to include private information about someone without their consent."
}
},
"porn": {
"settings": {
"name": "Pornography",
"description": "Images of full-frontal nudity (genitalia) in any sexualized context, or explicit sexual activity (meaning contact with genitalia or breasts) even if partially covered. Includes graphic sexual cartoons (often jokes/memes)."
},
"account": {
"name": "Pornography",
"description": "This account contains imagery of full-frontal nudity or explicit sexual activity."
},
"content": {
"name": "Pornography",
"description": "This content contains imagery of full-frontal nudity or explicit sexual activity."
}
},
"sexual": {
"settings": {
"name": "Sexually Suggestive",
"description": "Content that does not meet the level of \"pornography\", but is still sexual. Some common examples have been selfies and \"hornyposting\" with underwear on, or partially naked (naked but covered, eg with hands or from side perspective). Sheer/see-through nipples may end up in this category."
},
"account": {
"name": "Sexually Suggestive",
"description": "This account contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress."
},
"content": {
"name": "Sexually Suggestive",
"description": "This content contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress."
}
},
"nudity": {
"settings": {
"name": "Nudity",
"description": "Nudity which is not sexual, or that is primarily \"artistic\" in nature. For example: breastfeeding; classic art paintings and sculptures; newspaper images with some nudity; fashion modeling. \"Erotic photography\" is likely to end up in sexual or porn."
},
"account": {
"name": "Nudity",
"description": "This account contains imagery which portrays nudity in a non-sexual or artistic setting."
},
"content": {
"name": "Nudity",
"description": "This content contains imagery which portrays nudity in a non-sexual or artistic setting."
}
},
"nsfl": {
"settings": {
"name": "NSFL",
"description": "\"Not Suitable For Life.\" This includes graphic images like the infamous \"goatse\" (don't look it up)."
},
"account": {
"name": "Graphic Imagery (NSFL)",
"description": "This account contains graphic images which are often referred to as \"Not Suitable For Life.\""
},
"content": {
"name": "Graphic Imagery (NSFL)",
"description": "This content contains graphic images which are often referred to as \"Not Suitable For Life.\""
}
},
"corpse": {
"settings": {
"name": "Corpse",
"description": "Visual image of a dead human body in any context. Includes war images, hanging, funeral caskets. Does not include all figurative cases (cartoons), but can include realistic figurative images or renderings."
},
"account": {
"name": "Graphic Imagery (Corpse)",
"description": "This account contains images of a dead human body in any context. Includes war images, hanging, funeral caskets."
},
"content": {
"name": "Graphic Imagery (Corpse)",
"description": "This content contains images of a dead human body in any context. Includes war images, hanging, funeral caskets."
}
},
"gore": {
"settings": {
"name": "Gore",
"description": "Intended for shocking images, typically involving blood or visible wounds."
},
"account": {
"name": "Graphic Imagery (Gore)",
"description": "This account contains shocking images involving blood or visible wounds."
},
"content": {
"name": "Graphic Imagery (Gore)",
"description": "This content contains shocking images involving blood or visible wounds."
}
},
"torture": {
"settings": {
"name": "Torture",
"description": "Depictions of torture of a human or animal (animal cruelty)."
},
"account": {
"name": "Graphic Imagery (Torture)",
"description": "This account contains depictions of torture of a human or animal."
},
"content": {
"name": "Graphic Imagery (Torture)",
"description": "This content contains depictions of torture of a human or animal."
}
},
"self-harm": {
"settings": {
"name": "Self-Harm",
"description": "A visual depiction (photo or figurative) of cutting, suicide, or similar."
},
"account": {
"name": "Graphic Imagery (Self-Harm)",
"description": "This account includes depictions of cutting, suicide, or other forms of self-harm."
},
"content": {
"name": "Graphic Imagery (Self-Harm)",
"description": "This content includes depictions of cutting, suicide, or other forms of self-harm."
}
},
"intolerant-race": {
"settings": {
"name": "Racial Intolerance",
"description": "Hateful or intolerant content related to race."
},
"account": {
"name": "Intolerance (Racial)",
"description": "This account includes hateful or intolerant content related to race."
},
"content": {
"name": "Intolerance (Racial)",
"description": "This content includes hateful or intolerant views related to race."
}
},
"intolerant-gender": {
"settings": {
"name": "Gender Intolerance",
"description": "Hateful or intolerant content related to gender or gender identity."
},
"account": {
"name": "Intolerance (Gender)",
"description": "This account includes hateful or intolerant content related to gender or gender identity."
},
"content": {
"name": "Intolerance (Gender)",
"description": "This content includes hateful or intolerant views related to gender or gender identity."
}
},
"intolerant-sexual-orientation": {
"settings": {
"name": "Sexual Orientation Intolerance",
"description": "Hateful or intolerant content related to sexual preferences."
},
"account": {
"name": "Intolerance (Orientation)",
"description": "This account includes hateful or intolerant content related to sexual preferences."
},
"content": {
"name": "Intolerance (Orientation)",
"description": "This content includes hateful or intolerant views related to sexual preferences."
}
},
"intolerant-religion": {
"settings": {
"name": "Religious Intolerance",
"description": "Hateful or intolerant content related to religious views or practices."
},
"account": {
"name": "Intolerance (Religious)",
"description": "This account includes hateful or intolerant content related to religious views or practices."
},
"content": {
"name": "Intolerance (Religious)",
"description": "This content includes hateful or intolerant views related to religious views or practices."
}
},
"intolerant": {
"settings": {
"name": "Intolerance",
"description": "A catchall for hateful or intolerant content which is not covered elsewhere."
},
"account": {
"name": "Intolerance",
"description": "This account includes hateful or intolerant content."
},
"content": {
"name": "Intolerance",
"description": "This content includes hateful or intolerant views."
}
},
"icon-intolerant": {
"settings": {
"name": "Intolerant Iconography",
"description": "Visual imagery associated with a hate group, such as the KKK or Nazi, in any context (supportive, critical, documentary, etc)."
},
"account": {
"name": "Intolerant Iconography",
"description": "This account includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes."
},
"content": {
"name": "Intolerant Iconography",
"description": "This content includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes."
}
},
"trolling": {
"settings": {
"name": "Trolling",
"description": "Content which is intended to produce a negative reaction from other users."
},
"account": {
"name": "Trolling",
"description": "The moderators believe this account has published content intended to inflame users."
},
"content": {
"name": "Trolling",
"description": "The moderators believe this content is intended to inflame users."
}
},
"harassment": {
"settings": {
"name": "Harassment",
"description": "Repeated posts directed at a user or a group of users with the intent to produce a negative reaction."
},
"account": {
"name": "Harassment",
"description": "The moderators believe this account has published content directed at a user or a group of users with the intent to inflame."
},
"content": {
"name": "Harassment",
"description": "The moderators believe this content is directed at a user or a group of users with the intent to inflame."
}
},
"bullying": {
"settings": {
"name": "Bullying",
"description": "Statements or imagery published with the intent to bully, humiliate, or degrade."
},
"account": {
"name": "Bullying",
"description": "The moderators believe this account has published statements or imagery published with the intent to bully, humiliate, or degrade others."
},
"content": {
"name": "Bullying",
"description": "The moderators believe this content was published with the intent to bully, humiliate, or degrade others."
}
},
"threat": {
"settings": {
"name": "Threats",
"description": "Statements or imagery published with the intent to threaten, intimidate, or harm."
},
"account": {
"name": "Threats",
"description": "The moderators believe this account has published statements or imagery with the intent to threaten, intimidate, or harm others."
},
"content": {
"name": "Threats",
"description": "The moderators believe this content was published with the intent to threaten, intimidate, or harm others."
}
},
"disgusting": {
"settings": {
"name": "Disgusting",
"description": "Content which is gross, like an image of poop."
},
"account": {
"name": "Warning: Disgusting",
"description": "The moderators believe this account contains content which users may find disgusting."
},
"content": {
"name": "Warning: Disgusting",
"description": "The moderators believe users may find this content disgusting."
}
},
"upsetting": {
"settings": {
"name": "Upsetting",
"description": "Content which is upsetting, like a video of an accident."
},
"account": {
"name": "Warning: Upsetting",
"description": "The moderators believe this account contains content which users may find upsetting."
},
"content": {
"name": "Warning: Upsetting",
"description": "The moderators believe users may find this content upsetting."
}
},
"profane": {
"settings": {
"name": "Profane",
"description": "Content which includes excessive swearing or violates common sensibilities."
},
"account": {
"name": "Warning: Profane",
"description": "The moderators believe this account contains content which users may find profane."
},
"content": {
"name": "Warning: Profane",
"description": "The moderators believe users may find this content profane."
}
},
"politics": {
"settings": {
"name": "Politics",
"description": "Anything that discusses politics or political discourse."
},
"account": {
"name": "Warning: Politics",
"description": "This is not a violation. The moderators believe this account discusses politics or political discourse. This warning is only provided for users who wish to reduce the amount of politics in their experience."
},
"content": {
"name": "Warning: Politics",
"description": "This is not a violation. The moderators believe this content discusses politics or political discourse. This warning is only provided for users who wish to reduce the amount of politics in their experience."
}
},
"troubling": {
"settings": {
"name": "Troubling",
"description": "Content which can be difficult to process such as bad news."
},
"account": {
"name": "Warning: Troubling",
"description": "This is not a violation. The moderators believe this account discusses topics which can be difficult to process. This warning is only provided for users who wish to reduce the amount of troubling discussion in their experience."
},
"content": {
"name": "Warning: Troubling",
"description": "This is not a violation. The moderators believe this content discusses topics which can be difficult to process. This warning is only provided for users who wish to reduce the amount of troubling discussion in their experience."
}
},
"negative": {
"settings": {
"name": "Negative",
"description": "Statements which are critical, pessimistic, or generally negative."
},
"account": {
"name": "Warning: Negative",
"description": "This is not a violation. The moderators believe this account publishes statements which are critical, pessimistic, or generally negative. This warning is only provided for users who wish to reduce the amount of negativity in their experience."
},
"content": {
"name": "Warning: Negative",
"description": "This is not a violation. The moderators believe this content is critical, pessimistic, or generally negative. This warning is only provided for users who wish to reduce the amount of negativity in their experience."
}
},
"discourse": {
"settings": {
"name": "Discourse",
"description": "Drama, typically about some topic which is currently active in the network."
},
"account": {
"name": "Warning: Discourse",
"description": "This is not a violation. The moderators believe this account publishes statements regarding in-network drama or disputes (aka \"discourse\"). This warning is only provided for users who wish to reduce the amount of negativity in their experience."
},
"content": {
"name": "Warning: Discourse",
"description": "This is not a violation. The moderators believe this content relates to in-network drama or disputes (aka \"discourse\"). This warning is only provided for users who wish to reduce the amount of negativity in their experience."
}
},
"spoiler": {
"settings": {
"name": "Spoiler",
"description": "Discussion about film, TV, etc which gives away plot points."
},
"account": {
"name": "Spoiler Warning",
"description": "This account contains discussion about film, TV, etc which gives away plot points."
},
"content": {
"name": "Spoiler Warning",
"description": "This content contains discussion about film, TV, etc which gives away plot points."
}
},
"spam": {
"settings": {
"name": "Spam",
"description": "Repeat, low-quality messages which are clearly not designed to add to a conversation or space."
},
"account": {
"name": "Spam",
"description": "This account publishes repeat, low-quality messages which are clearly not designed to add to a conversation or space."
},
"content": {
"name": "Spam",
"description": "This content is a part of repeat, low-quality messages which are clearly not designed to add to a conversation or space."
}
},
"clickbait": {
"settings": {
"name": "Clickbait",
"description": "Low-quality content that's designed to get users to open an external link by appearing more engaging than it is."
},
"account": {
"name": "Clickbait",
"description": "The moderators believe this account publishes low-quality content that's designed to get users to open an external link by appearing more engaging than it is."
},
"content": {
"name": "Clickbait",
"description": "The moderators believe this is low-quality content that's designed to get users to open an external link by appearing more engaging than it is."
}
},
"shill": {
"settings": {
"name": "Shilling",
"description": "Over-enthusiastic promotion of a technology, product, or service, especially when there is a financial conflict of interest."
},
"account": {
"name": "Shill",
"description": "The moderators believe this account participates in over-enthusiastic promotion of a technology, product, or service."
},
"content": {
"name": "Shilling",
"description": "The moderators believe this content is in over-enthusiastic promotion of a technology, product, or service."
}
},
"promotion": {
"settings": {
"name": "Promotion",
"description": "Advertising or blunt marketing of a commercial service or product."
},
"account": {
"name": "Promotion",
"description": "The moderators believe this account engages in advertising or blunt marketing of a commercial service or product."
},
"content": {
"name": "Promotion",
"description": "The moderators believe this content is advertising or blunt marketing of a commercial service or product."
}
},
"account-security": {
"settings": {
"name": "Security Concerns",
"description": "Content designed to hijack user accounts such as a phishing attack."
},
"account": {
"name": "Security Warning",
"description": "This account has published content designed to hijack user accounts such as a phishing attack."
},
"content": {
"name": "Security Warning",
"description": "This content is designed to hijack user accounts such as a phishing attack."
}
},
"net-abuse": {
"settings": {
"name": "Network Attacks",
"description": "Content designed to attack network systems such as denial-of-service attacks."
},
"account": {
"name": "Network Attack Warning",
"description": "This account has published content designed to attack network systems such as denial-of-service attacks."
},
"content": {
"name": "Network Attack Warning",
"description": "This content is designed to attack network systems such as denial-of-service attacks."
}
},
"impersonation": {
"settings": {
"name": "Impersonation",
"description": "Accounts which falsely assert some identity."
},
"account": {
"name": "Impersonation Warning",
"description": "The moderators believe this account is lying about their identity."
},
"content": {
"name": "Impersonation Warning",
"description": "The moderators believe this account is lying about their identity."
}
},
"scam": {
"settings": {
"name": "Scam",
"description": "Fraudulent content."
},
"account": {
"name": "Scam Warning",
"description": "The moderators believe this account publishes fraudulent content."
},
"content": {
"name": "Scam Warning",
"description": "The moderators believe this is fraudulent content."
}
},
"misinformation": {
"settings": {
"name": "Misinformation",
"description": "Lies with the intent to deceive."
},
"account": {
"name": "Misinformation Warning",
"description": "The moderators believe this account has published lies with the intent to deceive."
},
"content": {
"name": "Misinformation Warning",
"description": "The moderators believe this content contains lies with the intent to deceive."
}
},
"unverified": {
"settings": {
"name": "Unverified Claims",
"description": "Assertions which have not been verified by a trusted source."
},
"account": {
"name": "Unverified Claims Warning",
"description": "The moderators believe this account has published claims which have not been verified by a trusted source."
},
"content": {
"name": "Unverified Claims Warning",
"description": "The moderators believe this content contains claims which have not been verified by a trusted source."
}
},
"manipulated": {
"settings": {
"name": "Manipulated Media",
"description": "Content which misrepresents a person or event by modifying the source material."
},
"account": {
"name": "Manipulated Media Warning",
"description": "The moderators believe this account has published content which misrepresents a person or event by modifying the source material."
},
"content": {
"name": "Manipulated Media Warning",
"description": "The moderators believe this content contains misrepresentations of a person or event by modifying the source material."
}
},
"fringe": {
"settings": {
"name": "Conspiracy Theories",
"description": "Fringe views which lack evidence."
},
"account": {
"name": "Conspiracy Theories Warning",
"description": "The moderators believe this account has published fringe views which lack evidence."
},
"content": {
"name": "Conspiracy Theories Warning",
"description": "The moderators believe this content contains fringe views which lack evidence."
}
},
"bullshit": {
"settings": {
"name": "Bullshit",
"description": "Content which is not technically wrong or lying, but misleading through omission or re-contextualization."
},
"account": {
"name": "Bullshit Warning",
"description": "The moderators believe this account has published content which is not technically wrong or lying, but misleading through omission or re-contextualization."
},
"content": {
"name": "Bullshit Warning",
"description": "The moderators believe this content includes statements which are not technically wrong or lying, but are misleading through omission or re-contextualization."
}
}
}

@ -1,50 +0,0 @@
import type { LabelPreference } from '../src'
export interface ModerationBehaviorResult {
cause?: string
filter?: boolean
blur?: boolean
alert?: boolean
noOverride?: boolean
}
export interface ModerationBehaviorScenario {
cfg: string
subject: 'post' | 'profile' | 'userlist' | 'feedgen'
author: string
quoteAuthor?: string
labels: {
post?: string[]
profile?: string[]
account?: string[]
quotedPost?: string[]
quotedAccount?: string[]
}
behaviors: {
content?: ModerationBehaviorResult
avatar?: ModerationBehaviorResult
embed?: ModerationBehaviorResult
}
}
export interface ModerationBehaviors {
users: Record<
string,
{
blocking: boolean
blockingByList: boolean
blockedBy: boolean
muted: boolean
mutedByList: boolean
}
>
configurations: Record<
string,
{
authed?: boolean
adultContentEnabled: boolean
settings: Record<string, LabelPreference>
}
>
scenarios: Record<string, ModerationBehaviorScenario>
}

File diff suppressed because it is too large Load Diff

@ -1,597 +0,0 @@
{
"users": {
"self": {
"blocking": false,
"blockingByList": false,
"blockedBy": false,
"muted": false,
"mutedByList": false
},
"alice": {
"blocking": false,
"blockingByList": false,
"blockedBy": false,
"muted": false,
"mutedByList": false
},
"bob": {
"blocking": true,
"blockingByList": false,
"blockedBy": false,
"muted": false,
"mutedByList": false
},
"carla": {
"blocking": false,
"blockingByList": false,
"blockedBy": true,
"muted": false,
"mutedByList": false
},
"dan": {
"blocking": false,
"blockingByList": false,
"blockedBy": false,
"muted": true,
"mutedByList": false
},
"elise": {
"blocking": false,
"blockingByList": false,
"blockedBy": false,
"muted": false,
"mutedByList": true
},
"fern": {
"blocking": true,
"blockingByList": false,
"blockedBy": true,
"muted": false,
"mutedByList": false
},
"georgia": {
"blocking": false,
"blockingByList": true,
"blockedBy": false,
"muted": false,
"mutedByList": false
}
},
"configurations": {
"none": {},
"adult-disabled": {
"adultContentEnabled": false
},
"intolerant-hide": {
"settings": { "intolerant": "hide" }
},
"intolerant-warn": {
"settings": { "intolerant": "warn" }
},
"intolerant-ignore": {
"settings": { "intolerant": "ignore" }
},
"porn-hide": {
"adultContentEnabled": true,
"settings": { "porn": "hide" }
},
"porn-warn": {
"adultContentEnabled": true,
"settings": { "porn": "warn" }
},
"porn-ignore": {
"adultContentEnabled": true,
"settings": { "porn": "ignore" }
},
"scam-hide": {
"settings": { "scam": "hide" }
},
"scam-warn": {
"settings": { "scam": "warn" }
},
"scam-ignore": {
"settings": { "scam": "ignore" }
},
"intolerant-hide-scam-warn": {
"settings": { "intolerant": "hide", "scam": "hide" }
},
"logged-out": {
"authed": false
}
},
"scenarios": {
"Imperative label ('!hide') on account": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!hide"] },
"behaviors": {
"account": {
"cause": "label:!hide",
"filter": true,
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Imperative label ('!hide') on profile": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["!hide"] },
"behaviors": {
"profile": { "cause": "label:!hide", "blur": true, "noOverride": true },
"avatar": { "blur": true, "noOverride": true }
}
},
"Imperative label ('!no-promote') on account": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!no-promote"] },
"behaviors": {
"account": { "cause": "label:!no-promote", "filter": true }
}
},
"Imperative label ('!no-promote') on profile": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["!no-promote"] },
"behaviors": {}
},
"Imperative label ('!warn') on account": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!warn"] },
"behaviors": {
"account": { "cause": "label:!warn", "blur": true },
"avatar": { "blur": true }
}
},
"Imperative label ('!warn') on profile": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["!warn"] },
"behaviors": {
"profile": { "cause": "label:!warn", "blur": true },
"avatar": { "blur": true }
}
},
"Imperative label ('!no-unauthenticated') on account when logged out": {
"cfg": "logged-out",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!no-unauthenticated"] },
"behaviors": {
"account": {
"cause": "label:!no-unauthenticated",
"filter": true,
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Imperative label ('!no-unauthenticated') on profile when logged out": {
"cfg": "logged-out",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["!no-unauthenticated"] },
"behaviors": {
"account": {
"cause": "label:!no-unauthenticated",
"filter": true,
"blur": true,
"noOverride": true
},
"profile": {
"cause": "label:!no-unauthenticated",
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Imperative label ('!no-unauthenticated') on account when logged in": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!no-unauthenticated"] },
"behaviors": {}
},
"Imperative label ('!no-unauthenticated') on profile when logged in": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["!no-unauthenticated"] },
"behaviors": {}
},
"Blur label ('intolerant') on account (hide)": {
"cfg": "intolerant-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["intolerant"] },
"behaviors": {
"account": {
"cause": "label:intolerant",
"filter": true,
"blur": true
},
"avatar": { "blur": true }
}
},
"Blur label ('intolerant') on profile (hide)": {
"cfg": "intolerant-hide",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["intolerant"] },
"behaviors": {
"profile": { "cause": "label:intolerant", "blur": true },
"avatar": { "blur": true }
}
},
"Blur label ('intolerant') on account (warn)": {
"cfg": "intolerant-warn",
"subject": "profile",
"author": "alice",
"labels": { "account": ["intolerant"] },
"behaviors": {
"account": { "cause": "label:intolerant", "blur": true },
"avatar": { "blur": true }
}
},
"Blur label ('intolerant') on profile (warn)": {
"cfg": "intolerant-warn",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["intolerant"] },
"behaviors": {
"profile": { "cause": "label:intolerant", "blur": true },
"avatar": { "blur": true }
}
},
"Blur label ('intolerant') on account (ignore)": {
"cfg": "intolerant-ignore",
"subject": "profile",
"author": "alice",
"labels": { "account": ["intolerant"] },
"behaviors": {}
},
"Blur label ('intolerant') on profile (ignore)": {
"cfg": "intolerant-ignore",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["intolerant"] },
"behaviors": {}
},
"Blur-media label ('porn') on account (hide)": {
"cfg": "porn-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["porn"] },
"behaviors": {
"account": { "cause": "label:porn", "filter": true, "blur": true },
"avatar": { "blur": true }
}
},
"Blur-media label ('porn') on profile (hide)": {
"cfg": "porn-hide",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["porn"] },
"behaviors": {
"avatar": { "blur": true }
}
},
"Blur-media label ('porn') on account (warn)": {
"cfg": "porn-warn",
"subject": "profile",
"author": "alice",
"labels": { "account": ["porn"] },
"behaviors": {
"account": { "cause": "label:porn", "blur": true },
"avatar": { "blur": true }
}
},
"Blur-media label ('porn') on profile (warn)": {
"cfg": "porn-warn",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["porn"] },
"behaviors": {
"avatar": { "blur": true }
}
},
"Blur-media label ('porn') on account (ignore)": {
"cfg": "porn-ignore",
"subject": "profile",
"author": "alice",
"labels": { "account": ["porn"] },
"behaviors": {}
},
"Blur-media label ('porn') on profile (ignore)": {
"cfg": "porn-ignore",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["porn"] },
"behaviors": {}
},
"Notice label ('scam') on account (hide)": {
"cfg": "scam-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["scam"] },
"behaviors": {
"account": { "cause": "label:scam", "filter": true, "alert": true },
"avatar": { "alert": true }
}
},
"Notice label ('scam') on profile (hide)": {
"cfg": "scam-hide",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["scam"] },
"behaviors": {
"profile": { "cause": "label:scam", "alert": true },
"avatar": { "alert": true }
}
},
"Notice label ('scam') on account (warn)": {
"cfg": "scam-warn",
"subject": "profile",
"author": "alice",
"labels": { "account": ["scam"] },
"behaviors": {
"account": { "cause": "label:scam", "alert": true },
"avatar": { "alert": true }
}
},
"Notice label ('scam') on profile (warn)": {
"cfg": "scam-warn",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["scam"] },
"behaviors": {
"profile": { "cause": "label:scam", "alert": true },
"avatar": { "alert": true }
}
},
"Notice label ('scam') on account (ignore)": {
"cfg": "scam-ignore",
"subject": "profile",
"author": "alice",
"labels": { "account": ["scam"] },
"behaviors": {}
},
"Notice label ('scam') on profile (ignore)": {
"cfg": "scam-ignore",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["scam"] },
"behaviors": {}
},
"Adult-only label on account when adult content is disabled": {
"cfg": "adult-disabled",
"subject": "profile",
"author": "alice",
"labels": { "account": ["porn"] },
"behaviors": {
"account": {
"cause": "label:porn",
"filter": true,
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Adult-only label on profile when adult content is disabled": {
"cfg": "adult-disabled",
"subject": "profile",
"author": "alice",
"labels": { "profile": ["porn"] },
"behaviors": {
"avatar": { "blur": true, "noOverride": true }
}
},
"Self-profile: !hide on account": {
"cfg": "none",
"subject": "profile",
"author": "self",
"labels": { "account": ["!hide"] },
"behaviors": {
"account": { "cause": "label:!hide", "alert": true },
"avatar": { "alert": true }
}
},
"Self-profile: !hide on profile": {
"cfg": "none",
"subject": "profile",
"author": "self",
"labels": { "profile": ["!hide"] },
"behaviors": {
"profile": { "cause": "label:!hide", "alert": true },
"avatar": { "alert": true }
}
},
"Mute/block: Blocking user": {
"cfg": "none",
"subject": "profile",
"author": "bob",
"labels": {},
"behaviors": {
"account": { "cause": "blocking", "filter": true },
"avatar": { "blur": true, "noOverride": true }
}
},
"Mute/block: Blocking-by-list user": {
"cfg": "none",
"subject": "profile",
"author": "georgia",
"labels": {},
"behaviors": {
"account": { "cause": "blocking-by-list", "filter": true },
"avatar": { "blur": true, "noOverride": true }
}
},
"Mute/block: Blocked by user": {
"cfg": "none",
"subject": "profile",
"author": "carla",
"labels": {},
"behaviors": {
"account": { "cause": "blocked-by", "filter": true },
"avatar": { "blur": true, "noOverride": true }
}
},
"Mute/block: Muted user": {
"cfg": "none",
"subject": "profile",
"author": "dan",
"labels": {},
"behaviors": {
"account": { "cause": "muted", "filter": true }
}
},
"Mute/block: Muted-by-list user": {
"cfg": "none",
"subject": "profile",
"author": "elise",
"labels": {},
"behaviors": {
"account": { "cause": "muted-by-list", "filter": true }
}
},
"Prioritization: blocking & blocked-by user": {
"cfg": "none",
"subject": "profile",
"author": "fern",
"labels": {},
"behaviors": {
"account": { "cause": "blocking", "filter": true, "blur": false },
"avatar": { "blur": true, "noOverride": true }
}
},
"Prioritization: '!hide' label on account of blocked user": {
"cfg": "none",
"subject": "profile",
"author": "bob",
"labels": { "account": ["!hide"] },
"behaviors": {
"account": {
"cause": "label:!hide",
"filter": true,
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Prioritization: '!hide' and 'intolerant' labels on account (hide)": {
"cfg": "intolerant-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!hide", "intolerant"] },
"behaviors": {
"account": {
"cause": "label:!hide",
"filter": true,
"blur": true,
"noOverride": true
},
"avatar": { "blur": true, "noOverride": true }
}
},
"Prioritization: '!warn' and 'intolerant' labels on account (hide)": {
"cfg": "intolerant-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!warn", "intolerant"] },
"behaviors": {
"account": {
"cause": "label:intolerant",
"filter": true,
"blur": true
},
"avatar": { "blur": true }
}
},
"Prioritization: '!warn' and 'porn' labels on account (hide)": {
"cfg": "porn-hide",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!warn", "porn"] },
"behaviors": {
"account": { "cause": "label:porn", "filter": true, "blur": true },
"avatar": { "blur": true }
}
},
"Prioritization: intolerant label on account (hide) and scam label on profile (warn)": {
"cfg": "intolerant-hide-scam-warn",
"subject": "profile",
"author": "alice",
"labels": { "account": ["intolerant"], "profile": ["scam"] },
"behaviors": {
"account": {
"cause": "label:intolerant",
"filter": true,
"blur": true
},
"profile": { "cause": "label:scam", "alert": true },
"avatar": { "blur": true, "alert": true }
}
},
"Prioritization: !hide on account, !warn on profile": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!hide"], "profile": ["!warn"] },
"behaviors": {
"account": {
"cause": "label:!hide",
"filter": true,
"blur": true,
"noOverride": true
},
"profile": { "cause": "label:!warn", "blur": true },
"avatar": { "blur": true, "noOverride": true }
}
},
"Prioritization: !warn on account, !hide on profile": {
"cfg": "none",
"subject": "profile",
"author": "alice",
"labels": { "account": ["!warn"], "profile": ["!hide"] },
"behaviors": {
"account": { "cause": "label:!warn", "blur": true },
"profile": { "cause": "label:!hide", "blur": true, "noOverride": true },
"avatar": { "blur": true, "noOverride": true }
}
}
}
}

@ -1,326 +0,0 @@
[
{
"id": "system",
"configurable": false,
"labels": [
{
"id": "!hide",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
},
{
"id": "!no-promote",
"preferences": ["hide"],
"flags": [],
"onwarn": null
},
{
"id": "!warn",
"preferences": ["warn"],
"flags": [],
"onwarn": "blur"
}
]
},
{
"id": "legal",
"configurable": false,
"labels": [
{
"id": "nudity-nonconsensual",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
},
{
"id": "dmca-violation",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
},
{
"id": "doxxing",
"preferences": ["hide"],
"flags": ["no-override"],
"onwarn": "blur"
}
]
},
{
"id": "sexual",
"configurable": true,
"labels": [
{
"id": "porn",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "sexual",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "nudity",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
}
]
},
{
"id": "violence",
"configurable": true,
"labels": [
{
"id": "nsfl",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "corpse",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "gore",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
},
{
"id": "torture",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur"
},
{
"id": "self-harm",
"preferences": ["ignore", "warn", "hide"],
"flags": ["adult"],
"onwarn": "blur-media"
}
]
},
{
"id": "intolerance",
"configurable": true,
"labels": [
{
"id": "intolerant-race",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant-gender",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant-sexual-orientation",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant-religion",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "intolerant",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "icon-intolerant",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur-media"
}
]
},
{
"id": "rude",
"configurable": true,
"labels": [
{
"id": "trolling",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "harassment",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "bullying",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "threat",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
}
]
},
{
"id": "curation",
"configurable": true,
"labels": [
{
"id": "disgusting",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "upsetting",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "profane",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "politics",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "troubling",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "negative",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "discourse",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "spoiler",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
}
]
},
{
"id": "spam",
"configurable": true,
"labels": [
{
"id": "spam",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "clickbait",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "shill",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "promotion",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
}
]
},
{
"id": "misinfo",
"configurable": true,
"labels": [
{
"id": "account-security",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "net-abuse",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "blur"
},
{
"id": "impersonation",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "scam",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "misinformation",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "unverified",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "manipulated",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "fringe",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
},
{
"id": "bullshit",
"preferences": ["ignore", "warn", "hide"],
"flags": [],
"onwarn": "alert"
}
]
}
]

@ -16,11 +16,9 @@ The possible client interpretations for a label.
- <code>warn</code> Provide some form of warning on the content (see "On Warn" behavior).
- <code>hide</code> Remove the content from feeds and apply the warning when directly viewed.
Each label specifies which preferences it can support. If a label is not configurable, it must have only own supported preference.
### Configurable?
Non-configurable labels cannot have their preference changed by the user.
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
@ -43,512 +41,68 @@ The kind of UI behavior used when a warning must be applied.
<table>
<tr>
<th>ID</th>
<th>Group</th>
<th>Preferences</th>
<th>Configurable</th>
<th>Flags</th>
<th>On Warn</th>
</tr>
<tr>
<td>!hide</td>
<td>system</td>
<td>hide</td>
<td></td>
<td>no-override</td>
<td>blur</td>
<td>❌ (undefined)</td>
<td>no-override, no-self</td>
<td>undefined</td>
</tr>
<tr>
<td>!no-promote</td>
<td>system</td>
<td>hide</td>
<td></td>
<td></td>
<td>null</td>
<td>❌ (undefined)</td>
<td>no-self</td>
<td>undefined</td>
</tr>
<tr>
<td>!warn</td>
<td>system</td>
<td>warn</td>
<td></td>
<td></td>
<td>blur</td>
<td>❌ (undefined)</td>
<td>no-self</td>
<td>undefined</td>
</tr>
<tr>
<td>!no-unauthenticated</td>
<td>system</td>
<td>hide</td>
<td></td>
<td>❌ (undefined)</td>
<td>no-override, unauthed</td>
<td>blur</td>
<td>undefined</td>
</tr>
<tr>
<td>dmca-violation</td>
<td>legal</td>
<td>hide</td>
<td></td>
<td>no-override</td>
<td>blur</td>
<td>❌ (undefined)</td>
<td>no-override, no-self</td>
<td>undefined</td>
</tr>
<tr>
<td>doxxing</td>
<td>legal</td>
<td>hide</td>
<td></td>
<td>no-override</td>
<td>blur</td>
<td>❌ (undefined)</td>
<td>no-override, no-self</td>
<td>undefined</td>
</tr>
<tr>
<td>porn</td>
<td>sexual</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
<td>undefined</td>
</tr>
<tr>
<td>sexual</td>
<td>sexual</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
<td>undefined</td>
</tr>
<tr>
<td>nudity</td>
<td>sexual</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
</tr>
<tr>
<td>nsfl</td>
<td>violence</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
</tr>
<tr>
<td>corpse</td>
<td>violence</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
<td>undefined</td>
</tr>
<tr>
<td>gore</td>
<td>violence</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
</tr>
<tr>
<td>torture</td>
<td>violence</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur</td>
</tr>
<tr>
<td>self-harm</td>
<td>violence</td>
<td>ignore, warn, hide</td>
<td></td>
<td>adult</td>
<td>blur-media</td>
</tr>
<tr>
<td>intolerant-race</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>intolerant-gender</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>intolerant-sexual-orientation</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>intolerant-religion</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>intolerant</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>icon-intolerant</td>
<td>intolerance</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur-media</td>
</tr>
<tr>
<td>threat</td>
<td>rude</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>spoiler</td>
<td>curation</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>spam</td>
<td>spam</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>account-security</td>
<td>misinfo</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>net-abuse</td>
<td>misinfo</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>blur</td>
</tr>
<tr>
<td>impersonation</td>
<td>misinfo</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>alert</td>
</tr>
<tr>
<td>scam</td>
<td>misinfo</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>alert</td>
</tr>
<tr>
<td>misleading</td>
<td>misinfo</td>
<td>ignore, warn, hide</td>
<td></td>
<td></td>
<td>alert</td>
</tr>
</table>
## Label Group Descriptions
<table>
<tr>
<th>ID</th>
<th>Description</th>
</tr>
<tr>
<td>system</td>
<td><code>general</code><br><strong>System</strong><br>Moderator overrides for special cases.</td>
</tr>
<tr>
<td>legal</td>
<td><code>general</code><br><strong>Legal</strong><br>Content removed for legal reasons.</td>
</tr>
<tr>
<td>sexual</td>
<td><code>general</code><br><strong>Adult Content</strong><br>Content which is sexual in nature.</td>
</tr>
<tr>
<td>violence</td>
<td><code>general</code><br><strong>Violence</strong><br>Content which is violent or deeply disturbing.</td>
</tr>
<tr>
<td>intolerance</td>
<td><code>general</code><br><strong>Intolerance</strong><br>Content or behavior which is hateful or intolerant toward a group of people.</td>
</tr>
<tr>
<td>rude</td>
<td><code>general</code><br><strong>Rude</strong><br>Behavior which is rude toward other users.</td>
</tr>
<tr>
<td>curation</td>
<td><code>general</code><br><strong>Curational</strong><br>Subjective moderation geared towards curating a more positive environment.</td>
</tr>
<tr>
<td>spam</td>
<td><code>general</code><br><strong>Spam</strong><br>Content which doesn't add to the conversation.</td>
</tr>
<tr>
<td>misinfo</td>
<td><code>general</code><br><strong>Misinformation</strong><br>Content which misleads or defrauds users.</td>
</tr>
</table>
## Label Descriptions
<table>
<tr>
<th>ID</th>
<th>Description</th>
</tr>
<tr>
<td>!hide</td>
<td>
<code>general</code><br><strong>Moderator Hide</strong><br>Moderator has chosen to hide the content.<br><br>
<code>on an account</code><br><strong>Content Blocked</strong><br>This account has been hidden by the moderators.<br><br>
<code>on content</code><br><strong>Content Blocked</strong><br>This content has been hidden by the moderators.<br><br>
</td>
</tr>
<tr>
<td>!no-promote</td>
<td>
<code>general</code><br><strong>Moderator Filter</strong><br>Moderator has chosen to filter the content from feeds.<br><br>
<code>on an account</code><br><strong>N/A</strong><br>N/A<br><br>
<code>on content</code><br><strong>N/A</strong><br>N/A<br><br>
</td>
</tr>
<tr>
<td>!warn</td>
<td>
<code>general</code><br><strong>Moderator Warn</strong><br>Moderator has chosen to set a general warning on the content.<br><br>
<code>on an account</code><br><strong>Content Warning</strong><br>This account has received a general warning from moderators.<br><br>
<code>on content</code><br><strong>Content Warning</strong><br>This content has received a general warning from moderators.<br><br>
</td>
</tr>
<tr>
<td>!no-unauthenticated</td>
<td>
<code>general</code><br><strong>Sign-in Required</strong><br>This user has requested that their account only be shown to signed-in users.<br><br>
<code>on an account</code><br><strong>Sign-in Required</strong><br>This user has requested that their account only be shown to signed-in users.<br><br>
<code>on content</code><br><strong>Sign-in Required</strong><br>This user has requested that their content only be shown to signed-in users.<br><br>
</td>
</tr>
<tr>
<td>dmca-violation</td>
<td>
<code>general</code><br><strong>Copyright Violation</strong><br>The content has received a DMCA takedown request.<br><br>
<code>on an account</code><br><strong>Copyright Violation</strong><br>This account has received a DMCA takedown request. It will be restored if the concerns can be resolved.<br><br>
<code>on content</code><br><strong>Copyright Violation</strong><br>This content has received a DMCA takedown request. It will be restored if the concerns can be resolved.<br><br>
</td>
</tr>
<tr>
<td>doxxing</td>
<td>
<code>general</code><br><strong>Doxxing</strong><br>Information that reveals private information about someone which has been shared without the consent of the subject.<br><br>
<code>on an account</code><br><strong>Doxxing</strong><br>This account has been reported to publish private information about someone without their consent. This report is currently under review.<br><br>
<code>on content</code><br><strong>Doxxing</strong><br>This content has been reported to include private information about someone without their consent.<br><br>
</td>
</tr>
<tr>
<td>porn</td>
<td>
<code>general</code><br><strong>Pornography</strong><br>Images of full-frontal nudity (genitalia) in any sexualized context, or explicit sexual activity (meaning contact with genitalia or breasts) even if partially covered. Includes graphic sexual cartoons (often jokes/memes).<br><br>
<code>on an account</code><br><strong>Adult Content</strong><br>This account contains imagery of full-frontal nudity or explicit sexual activity.<br><br>
<code>on content</code><br><strong>Adult Content</strong><br>This content contains imagery of full-frontal nudity or explicit sexual activity.<br><br>
</td>
</tr>
<tr>
<td>sexual</td>
<td>
<code>general</code><br><strong>Sexually Suggestive</strong><br>Content that does not meet the level of "pornography", but is still sexual. Some common examples have been selfies and "hornyposting" with underwear on, or partially naked (naked but covered, eg with hands or from side perspective). Sheer/see-through nipples may end up in this category.<br><br>
<code>on an account</code><br><strong>Suggestive Content</strong><br>This account contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.<br><br>
<code>on content</code><br><strong>Suggestive Content</strong><br>This content contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.<br><br>
</td>
</tr>
<tr>
<td>nudity</td>
<td>
<code>general</code><br><strong>Nudity</strong><br>Nudity which is not sexual, or that is primarily "artistic" in nature. For example: breastfeeding; classic art paintings and sculptures; newspaper images with some nudity; fashion modeling. "Erotic photography" is likely to end up in sexual or porn.<br><br>
<code>on an account</code><br><strong>Adult Content</strong><br>This account contains imagery which portrays nudity in a non-sexual or artistic setting.<br><br>
<code>on content</code><br><strong>Adult Content</strong><br>This content contains imagery which portrays nudity in a non-sexual or artistic setting.<br><br>
</td>
</tr>
<tr>
<td>nsfl</td>
<td>
<code>general</code><br><strong>NSFL</strong><br>"Not Suitable For Life." This includes graphic images like the infamous "goatse" (don't look it up).<br><br>
<code>on an account</code><br><strong>Graphic Imagery (NSFL)</strong><br>This account contains graphic images which are often referred to as "Not Suitable For Life."<br><br>
<code>on content</code><br><strong>Graphic Imagery (NSFL)</strong><br>This content contains graphic images which are often referred to as "Not Suitable For Life."<br><br>
</td>
</tr>
<tr>
<td>corpse</td>
<td>
<code>general</code><br><strong>Corpse</strong><br>Visual image of a dead human body in any context. Includes war images, hanging, funeral caskets. Does not include all figurative cases (cartoons), but can include realistic figurative images or renderings.<br><br>
<code>on an account</code><br><strong>Graphic Imagery (Corpse)</strong><br>This account contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.<br><br>
<code>on content</code><br><strong>Graphic Imagery (Corpse)</strong><br>This content contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.<br><br>
</td>
</tr>
<tr>
<td>gore</td>
<td>
<code>general</code><br><strong>Gore</strong><br>Intended for shocking images, typically involving blood or visible wounds.<br><br>
<code>on an account</code><br><strong>Graphic Imagery (Gore)</strong><br>This account contains shocking images involving blood or visible wounds.<br><br>
<code>on content</code><br><strong>Graphic Imagery (Gore)</strong><br>This content contains shocking images involving blood or visible wounds.<br><br>
</td>
</tr>
<tr>
<td>torture</td>
<td>
<code>general</code><br><strong>Torture</strong><br>Depictions of torture of a human or animal (animal cruelty).<br><br>
<code>on an account</code><br><strong>Graphic Imagery (Torture)</strong><br>This account contains depictions of torture of a human or animal.<br><br>
<code>on content</code><br><strong>Graphic Imagery (Torture)</strong><br>This content contains depictions of torture of a human or animal.<br><br>
</td>
</tr>
<tr>
<td>self-harm</td>
<td>
<code>general</code><br><strong>Self-Harm</strong><br>A visual depiction (photo or figurative) of cutting, suicide, or similar.<br><br>
<code>on an account</code><br><strong>Graphic Imagery (Self-Harm)</strong><br>This account includes depictions of cutting, suicide, or other forms of self-harm.<br><br>
<code>on content</code><br><strong>Graphic Imagery (Self-Harm)</strong><br>This content includes depictions of cutting, suicide, or other forms of self-harm.<br><br>
</td>
</tr>
<tr>
<td>intolerant-race</td>
<td>
<code>general</code><br><strong>Racial Intolerance</strong><br>Hateful or intolerant content related to race.<br><br>
<code>on an account</code><br><strong>Intolerance (Racial)</strong><br>This account includes hateful or intolerant content related to race.<br><br>
<code>on content</code><br><strong>Intolerance (Racial)</strong><br>This content includes hateful or intolerant views related to race.<br><br>
</td>
</tr>
<tr>
<td>intolerant-gender</td>
<td>
<code>general</code><br><strong>Gender Intolerance</strong><br>Hateful or intolerant content related to gender or gender identity.<br><br>
<code>on an account</code><br><strong>Intolerance (Gender)</strong><br>This account includes hateful or intolerant content related to gender or gender identity.<br><br>
<code>on content</code><br><strong>Intolerance (Gender)</strong><br>This content includes hateful or intolerant views related to gender or gender identity.<br><br>
</td>
</tr>
<tr>
<td>intolerant-sexual-orientation</td>
<td>
<code>general</code><br><strong>Sexual Orientation Intolerance</strong><br>Hateful or intolerant content related to sexual preferences.<br><br>
<code>on an account</code><br><strong>Intolerance (Orientation)</strong><br>This account includes hateful or intolerant content related to sexual preferences.<br><br>
<code>on content</code><br><strong>Intolerance (Orientation)</strong><br>This content includes hateful or intolerant views related to sexual preferences.<br><br>
</td>
</tr>
<tr>
<td>intolerant-religion</td>
<td>
<code>general</code><br><strong>Religious Intolerance</strong><br>Hateful or intolerant content related to religious views or practices.<br><br>
<code>on an account</code><br><strong>Intolerance (Religious)</strong><br>This account includes hateful or intolerant content related to religious views or practices.<br><br>
<code>on content</code><br><strong>Intolerance (Religious)</strong><br>This content includes hateful or intolerant views related to religious views or practices.<br><br>
</td>
</tr>
<tr>
<td>intolerant</td>
<td>
<code>general</code><br><strong>Intolerance</strong><br>A catchall for hateful or intolerant content which is not covered elsewhere.<br><br>
<code>on an account</code><br><strong>Intolerance</strong><br>This account includes hateful or intolerant content.<br><br>
<code>on content</code><br><strong>Intolerance</strong><br>This content includes hateful or intolerant views.<br><br>
</td>
</tr>
<tr>
<td>icon-intolerant</td>
<td>
<code>general</code><br><strong>Intolerant Iconography</strong><br>Visual imagery associated with a hate group, such as the KKK or Nazi, in any context (supportive, critical, documentary, etc).<br><br>
<code>on an account</code><br><strong>Intolerant Iconography</strong><br>This account includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes.<br><br>
<code>on content</code><br><strong>Intolerant Iconography</strong><br>This content includes imagery associated with a hate group such as the KKK or Nazis. This warning may apply to content any context, including critical or documentary purposes.<br><br>
</td>
</tr>
<tr>
<td>threat</td>
<td>
<code>general</code><br><strong>Threats</strong><br>Statements or imagery published with the intent to threaten, intimidate, or harm.<br><br>
<code>on an account</code><br><strong>Threats</strong><br>The moderators believe this account has published statements or imagery with the intent to threaten, intimidate, or harm others.<br><br>
<code>on content</code><br><strong>Threats</strong><br>The moderators believe this content was published with the intent to threaten, intimidate, or harm others.<br><br>
</td>
</tr>
<tr>
<td>spoiler</td>
<td>
<code>general</code><br><strong>Spoiler</strong><br>Discussion about film, TV, etc which gives away plot points.<br><br>
<code>on an account</code><br><strong>Spoiler Warning</strong><br>This account contains discussion about film, TV, etc which gives away plot points.<br><br>
<code>on content</code><br><strong>Spoiler Warning</strong><br>This content contains discussion about film, TV, etc which gives away plot points.<br><br>
</td>
</tr>
<tr>
<td>spam</td>
<td>
<code>general</code><br><strong>Spam</strong><br>Repeat, low-quality messages which are clearly not designed to add to a conversation or space.<br><br>
<code>on an account</code><br><strong>Spam</strong><br>This account publishes repeat, low-quality messages which are clearly not designed to add to a conversation or space.<br><br>
<code>on content</code><br><strong>Spam</strong><br>This content is a part of repeat, low-quality messages which are clearly not designed to add to a conversation or space.<br><br>
</td>
</tr>
<tr>
<td>account-security</td>
<td>
<code>general</code><br><strong>Security Concerns</strong><br>Content designed to hijack user accounts such as a phishing attack.<br><br>
<code>on an account</code><br><strong>Security Warning</strong><br>This account has published content designed to hijack user accounts such as a phishing attack.<br><br>
<code>on content</code><br><strong>Security Warning</strong><br>This content is designed to hijack user accounts such as a phishing attack.<br><br>
</td>
</tr>
<tr>
<td>net-abuse</td>
<td>
<code>general</code><br><strong>Network Attacks</strong><br>Content designed to attack network systems such as denial-of-service attacks.<br><br>
<code>on an account</code><br><strong>Network Attack Warning</strong><br>This account has published content designed to attack network systems such as denial-of-service attacks.<br><br>
<code>on content</code><br><strong>Network Attack Warning</strong><br>This content is designed to attack network systems such as denial-of-service attacks.<br><br>
</td>
</tr>
<tr>
<td>impersonation</td>
<td>
<code>general</code><br><strong>Impersonation</strong><br>Accounts which falsely assert some identity.<br><br>
<code>on an account</code><br><strong>Impersonation Warning</strong><br>The moderators believe this account is lying about their identity.<br><br>
<code>on content</code><br><strong>Impersonation Warning</strong><br>The moderators believe this account is lying about their identity.<br><br>
</td>
</tr>
<tr>
<td>scam</td>
<td>
<code>general</code><br><strong>Scam</strong><br>Fraudulent content.<br><br>
<code>on an account</code><br><strong>Scam Warning</strong><br>The moderators believe this account publishes fraudulent content.<br><br>
<code>on content</code><br><strong>Scam Warning</strong><br>The moderators believe this is fraudulent content.<br><br>
</td>
</tr>
<tr>
<td>misleading</td>
<td>
<code>general</code><br><strong>Misleading</strong><br>Accounts which share misleading information.<br><br>
<code>on an account</code><br><strong>Misleading</strong><br>The moderators believe this account is spreading misleading information.<br><br>
<code>on content</code><br><strong>Misleading</strong><br>The moderators believe this account is spreading misleading information.<br><br>
</td>
<td>undefined</td>
</tr>
</table>

File diff suppressed because it is too large Load Diff

@ -1,833 +0,0 @@
<!-- this doc is generated by ./scripts/docs/profile-moderation-behaviors.mjs -->
# Profile moderation behaviors
This document is a reference for the expected behaviors for a profile in the application based on some given scenarios. The <code>moderateProfile()</code> command condense down to the following yes or no decisions:
- <code>res.account.filter</code> Do not show the account in feeds.
- <code>res.account.blur</code> Put the account (in listings, when viewing) behind a warning cover.
- <code>res.account.noOverride</code> Do not allow the account's blur cover to be lifted.
- <code>res.account.alert</code> Add a warning to the account but do not cover it.
- <code>res.profile.blur</code> Put the profile details (handle, display name, bio) behind a warning cover.
- <code>res.profile.noOverride</code> Do not allow the profile's blur cover to be lifted.
- <code>res.profile.alert</code> Add a warning to the profile but do not cover it.
- <code>res.avatar.blur</code> Put the avatar behind a cover.
- <code>res.avatar.noOverride</code> Do not allow the avatars's blur cover to be lifted.
- <code>res.avatar.alert</code> Put a warning icon on the avatar.
Key:
- ❌ = Filter Content
- 🚫 = Blur (no-override)
- ✋ = Blur
- 🪧 = Alert
## Scenarios
<table>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Imperative label ('!hide') on account</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Imperative label ('!hide') on profile</strong></td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-promote') on account</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-promote') on profile</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Imperative label ('!warn') on account</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Imperative label ('!warn') on profile</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-unauthenticated') on account when logged out</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-unauthenticated') on profile when logged out</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
🚫
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-unauthenticated') on account when logged in</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Imperative label ('!no-unauthenticated') on profile when logged in</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Blur label ('intolerant') on account (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur label ('intolerant') on profile (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur label ('intolerant') on account (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur label ('intolerant') on profile (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur label ('intolerant') on account (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur label ('intolerant') on profile (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Blur-media label ('porn') on account (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur-media label ('porn') on profile (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur-media label ('porn') on account (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur-media label ('porn') on profile (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur-media label ('porn') on account (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Blur-media label ('porn') on profile (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Notice label ('scam') on account (hide)</strong></td>
<td>
</td>
<td>
🪧
</td>
<td>
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Notice label ('scam') on profile (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
🪧
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Notice label ('scam') on account (warn)</strong></td>
<td>
</td>
<td>
🪧
</td>
<td>
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Notice label ('scam') on profile (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
🪧
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Notice label ('scam') on account (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Notice label ('scam') on profile (ignore)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Adult-only label on account when adult content is disabled</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Adult-only label on profile when adult content is disabled</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Self-profile: !hide on account</strong></td>
<td>
</td>
<td>
🪧
</td>
<td>
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Self-profile: !hide on profile</strong></td>
<td>
</td>
<td>
</td>
<td>
🪧
</td>
<td>
🪧
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Mute/block: Blocking user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Mute/block: Blocking-by-list user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Mute/block: Blocked by user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Mute/block: Muted user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Mute/block: Muted-by-list user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>
<tr>
<td><strong>Prioritization: blocking & blocked-by user</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Prioritization: '!hide' label on account of blocked user</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Prioritization: '!hide' and 'intolerant' labels on account (hide)</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Prioritization: '!warn' and 'intolerant' labels on account (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Prioritization: '!warn' and 'porn' labels on account (hide)</strong></td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td><strong>Prioritization: intolerant label on account (hide) and scam label on profile (warn)</strong></td>
<td>
</td>
<td>
</td>
<td>
🪧
</td>
<td>
🪧
</td>
</tr>
<tr>
<td><strong>Prioritization: !hide on account, !warn on profile</strong></td>
<td>
</td>
<td>
🚫
</td>
<td>
</td>
<td>
🚫
</td>
</tr>
<tr>
<td><strong>Prioritization: !warn on account, !hide on profile</strong></td>
<td>
</td>
<td>
</td>
<td>
🚫
</td>
<td>
🚫
</td>
</tr>
</table>

@ -11,8 +11,6 @@ For more information, see the [Moderation Documentation](./docs/moderation.md) o
Additional docs:
- [Labels Reference](./labels.md)
- [Post Moderation Behaviors](./moderation-behaviors/posts.md)
- [Profile Moderation Behaviors](./moderation-behaviors/profiles.md)
## Configuration

@ -1,68 +0,0 @@
import * as url from 'url'
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import * as prettier from 'prettier'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const labelsDef = JSON.parse(
readFileSync(
join(__dirname, '..', '..', 'definitions', 'labels.json'),
'utf8',
),
)
const labelGroupsEn = JSON.parse(
readFileSync(
join(
__dirname,
'..',
'..',
'definitions',
'locale',
'en',
'label-groups.json',
),
'utf8',
),
)
writeFileSync(
join(__dirname, '..', '..', 'src', 'moderation', 'const', 'label-groups.ts'),
await gen(),
'utf8',
)
async function gen() {
return prettier.format(
`/** this doc is generated by ./scripts/code/labels.mjs **/
import {LabelGroupDefinitionMap} from '../types'
import {LABELS} from './labels'
export const LABEL_GROUPS: LabelGroupDefinitionMap = {
${genDefMap()}
}
`,
{ semi: false, parser: 'babel', singleQuote: true },
)
}
function genDefMap() {
const lines = []
for (const group of labelsDef) {
lines.push(`"${group.id}": {`)
lines.push(` id: "${group.id}",`)
lines.push(` configurable: ${group.configurable ? true : false},`)
lines.push(
` labels: [${group.labels
.map((label) => `LABELS["${label.id}"]`)
.join(', ')}],`,
)
lines.push(
` strings: {settings: {en: ${JSON.stringify(labelGroupsEn[group.id])}}}`,
)
lines.push(`},`)
}
return lines.join('\n')
}
export {}

@ -11,12 +11,6 @@ const labelsDef = JSON.parse(
'utf8',
),
)
const labelsEn = JSON.parse(
readFileSync(
join(__dirname, '..', '..', 'definitions', 'locale', 'en', 'labels.json'),
'utf8',
),
)
writeFileSync(
join(__dirname, '..', '..', 'src', 'moderation', 'const', 'labels.ts'),
@ -27,10 +21,24 @@ writeFileSync(
async function gen() {
return prettier.format(
`/** this doc is generated by ./scripts/code/labels.mjs **/
import {LabelDefinitionMap} from '../types'
import {InterprettedLabelValueDefinition, LabelPreference} from '../types'
export const LABELS: LabelDefinitionMap = ${JSON.stringify(
genDefMap(),
export type KnownLabelValue = ${labelsDef
.map((label) => `"${label.identifier}"`)
.join(' | ')}
export const DEFAULT_LABEL_SETTINGS: Record<string, LabelPreference> = ${JSON.stringify(
Object.fromEntries(
labelsDef
.filter((label) => label.configurable)
.map((label) => [label.identifier, label.defaultSetting]),
),
)}
export const LABELS: Record<KnownLabelValue, InterprettedLabelValueDefinition> = ${JSON.stringify(
Object.fromEntries(
labelsDef.map((label) => [label.identifier, { ...label, locales: [] }]),
),
null,
2,
)}
@ -39,30 +47,4 @@ async function gen() {
)
}
function genDefMap() {
const labels = {}
for (const group of labelsDef) {
for (const label of group.labels) {
labels[label.id] = {
...label,
groupId: group.id,
configurable: group.configurable,
strings: {
settings: getLabelStrings(label.id, 'settings'),
account: getLabelStrings(label.id, 'account'),
content: getLabelStrings(label.id, 'content'),
},
}
}
}
return labels
}
function getLabelStrings(id, type) {
if (labelsEn[id] && labelsEn[id][type]) {
return { en: labelsEn[id][type] }
}
throw new Error('Label strings not found for ' + id)
}
export {}

@ -11,26 +11,6 @@ const labelsDef = JSON.parse(
'utf8',
),
)
const labelGroupsEn = JSON.parse(
readFileSync(
join(
__dirname,
'..',
'..',
'definitions',
'locale',
'en',
'label-groups.json',
),
'utf8',
),
)
const labelsEn = JSON.parse(
readFileSync(
join(__dirname, '..', '..', 'definitions', 'locale', 'en', 'labels.json'),
'utf8',
),
)
writeFileSync(join(__dirname, '..', '..', 'docs', 'labels.md'), doc(), 'utf8')
@ -54,11 +34,9 @@ The possible client interpretations for a label.
- <code>warn</code> Provide some form of warning on the content (see "On Warn" behavior).
- <code>hide</code> Remove the content from feeds and apply the warning when directly viewed.
Each label specifies which preferences it can support. If a label is not configurable, it must have only own supported preference.
### Configurable?
Non-configurable labels cannot have their preference changed by the user.
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
@ -81,82 +59,27 @@ The kind of UI behavior used when a warning must be applied.
<table>
<tr>
<th>ID</th>
<th>Group</th>
<th>Preferences</th>
<th>Configurable</th>
<th>Flags</th>
<th>On Warn</th>
</tr>
${labelsRef()}
</table>
## Label Group Descriptions
<table>
<tr>
<th>ID</th>
<th>Description</th>
</tr>
${labelGroupsDesc()}
</table>
## Label Descriptions
<table>
<tr>
<th>ID</th>
<th>Description</th>
</tr>
${labelsDesc()}
</table>
`
</table>`
}
function labelsRef() {
const lines = []
for (const group of labelsDef) {
for (const label of group.labels) {
lines.push(stripIndent`
for (const label of labelsDef) {
lines.push(stripIndent`
<tr>
<td>${label.id}</td>
<td>${group.id}</td>
<td>${label.preferences.join(', ')}</td>
<td>${group.configurable ? '✅' : '❌'}</td>
<td>${label.identifier}</td>
<td>${
label.configurable ? '✅' : `❌ (${label.fixedPreference})`
}</td>
<td>${label.flags.join(', ')}</td>
<td>${label.onwarn}</td>
</tr>
`)
}
}
return lines.join('\n')
}
function labelGroupsDesc() {
const lines = []
for (const id in labelGroupsEn) {
lines.push(stripIndent`
<tr>
<td>${id}</td>
<td><code>general</code><br><strong>${labelGroupsEn[id].name}</strong><br>${labelGroupsEn[id].description}</td>
</tr>
`)
}
return lines.join('\n')
}
function labelsDesc() {
const lines = []
for (const id in labelsEn) {
lines.push(stripIndent`
<tr>
<td>${id}</td>
<td>
<code>general</code><br><strong>${labelsEn[id].settings.name}</strong><br>${labelsEn[id].settings.description}<br><br>
<code>on an account</code><br><strong>${labelsEn[id].account.name}</strong><br>${labelsEn[id].account.description}<br><br>
<code>on content</code><br><strong>${labelsEn[id].content.name}</strong><br>${labelsEn[id].content.description}<br><br>
</td>
</tr>
`)
}
return lines.join('\n')
}

@ -1,117 +0,0 @@
import * as url from 'url'
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { stripIndents } from 'common-tags'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const postModerationBehaviorsDef = JSON.parse(
readFileSync(
join(
__dirname,
'..',
'..',
'definitions',
'post-moderation-behaviors.json',
),
'utf8',
),
)
writeFileSync(
join(__dirname, '..', '..', 'docs', 'moderation-behaviors', 'posts.md'),
posts(),
'utf8',
)
function posts() {
let lastTitle = 'NULL'
return stripIndents`
<!-- this doc is generated by ./scripts/docs/post-moderation-behaviors.mjs -->
# Post moderation behaviors
This document is a reference for the expected behaviors for a post in the application based on some given scenarios. The <code>moderatePost()</code> command condense down to the following yes or no decisions:
- <code>res.content.filter</code> Do not show the post in feeds.
- <code>res.content.blur</code> Put the post behind a warning cover.
- <code>res.content.noOverride</code> Do not allow the post's blur cover to be lifted.
- <code>res.content.alert</code> Add a warning to the post but do not cover it.
- <code>res.avatar.blur</code> Put the avatar behind a cover.
- <code>res.avatar.noOverride</code> Do not allow the avatars's blur cover to be lifted.
- <code>res.avatar.alert</code> Put a warning icon on the avatar.
- <code>res.embed.blur</code> Put the embed content (media, quote post) behind a warning cover.
- <code>res.embed.noOverride</code> Do not allow the embed's blur cover to be lifted.
- <code>res.embed.alert</code> Put a warning on the embed content (media, quote post).
Key:
- = Filter Content
- 🚫 = Blur (no-override)
- = Blur
- 🪧 = Alert
## Scenarios
<table>
${Array.from(Object.entries(postModerationBehaviorsDef.scenarios))
.map(([title, scenario], i) => {
const str = `
${title.indexOf(lastTitle) === -1 ? postTableHead() : ''}
${scenarioSection(title, scenario)}
`
lastTitle = title.slice(0, 10)
return str
})
.join('')}
</table>
`
}
function postTableHead() {
return `<tr><th>Scenario</th><th>Filter</th><th>Content</th><th>Avatar</th><th>Embed</th></tr>`
}
function scenarioSection(title, scenario) {
return stripIndents`<tr>
<td><strong>${title}</strong></td>
<td>
${filter(scenario.behaviors.content?.filter)}
</td>
<td>
${blur(
scenario.behaviors.content?.blur,
scenario.behaviors.content?.noOverride,
)}${alert(scenario.behaviors.content?.alert)}
</td>
<td>
${blur(
scenario.behaviors.avatar?.blur,
scenario.behaviors.avatar?.noOverride,
)}${alert(scenario.behaviors.avatar?.alert)}
</td>
<td>
${blur(
scenario.behaviors.embed?.blur,
scenario.behaviors.embed?.noOverride,
)}${alert(scenario.behaviors.embed?.alert)}
</td>
</tr>`
}
function filter(val) {
return val ? '❌' : ''
}
function blur(val, noOverride) {
if (val) {
return noOverride ? '🚫' : '✋'
}
return ''
}
function alert(val) {
return val ? '🪧' : ''
}
export {}

@ -1,122 +0,0 @@
import * as url from 'url'
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { stripIndents } from 'common-tags'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const profileModerationBehaviorsDef = JSON.parse(
readFileSync(
join(
__dirname,
'..',
'..',
'definitions',
'profile-moderation-behaviors.json',
),
'utf8',
),
)
writeFileSync(
join(__dirname, '..', '..', 'docs', 'moderation-behaviors', 'profiles.md'),
profiles(),
'utf8',
)
function profiles() {
let lastTitle = 'NULL'
return stripIndents`
<!-- this doc is generated by ./scripts/docs/profile-moderation-behaviors.mjs -->
# Profile moderation behaviors
This document is a reference for the expected behaviors for a profile in the application based on some given scenarios. The <code>moderateProfile()</code> command condense down to the following yes or no decisions:
- <code>res.account.filter</code> Do not show the account in feeds.
- <code>res.account.blur</code> Put the account (in listings, when viewing) behind a warning cover.
- <code>res.account.noOverride</code> Do not allow the account's blur cover to be lifted.
- <code>res.account.alert</code> Add a warning to the account but do not cover it.
- <code>res.profile.blur</code> Put the profile details (handle, display name, bio) behind a warning cover.
- <code>res.profile.noOverride</code> Do not allow the profile's blur cover to be lifted.
- <code>res.profile.alert</code> Add a warning to the profile but do not cover it.
- <code>res.avatar.blur</code> Put the avatar behind a cover.
- <code>res.avatar.noOverride</code> Do not allow the avatars's blur cover to be lifted.
- <code>res.avatar.alert</code> Put a warning icon on the avatar.
Key:
- = Filter Content
- 🚫 = Blur (no-override)
- = Blur
- 🪧 = Alert
## Scenarios
<table>
${Array.from(Object.entries(profileModerationBehaviorsDef.scenarios))
.map(([title, scenario], i) => {
const str = `
${title.indexOf(lastTitle) === -1 ? postTableHead() : ''}
${scenarioSection(title, scenario)}
`
lastTitle = title.slice(0, 10)
return str
})
.join('\n\n')}
</table>
`
}
function postTableHead() {
return `<tr><th>Scenario</th><th>Filter</th><th>Account</th><th>Profile</td><th>Avatar</th></tr>`
}
function scenarioSection(title, scenario) {
return stripIndents`
<tr>
<td><strong>${title}</strong></td>
<td>
${filter(scenario.behaviors.account?.filter)}
</td>
<td>
${blur(
scenario.behaviors.account?.blur,
scenario.behaviors.account?.noOverride,
)}
${alert(scenario.behaviors.account?.alert)}
</td>
<td>
${blur(
scenario.behaviors.profile?.blur,
scenario.behaviors.profile?.noOverride,
)}
${alert(scenario.behaviors.profile?.alert)}
</td>
<td>
${blur(
scenario.behaviors.avatar?.blur,
scenario.behaviors.avatar?.noOverride,
)}
${alert(scenario.behaviors.avatar?.alert)}
</td>
</tr>
`
}
function filter(val) {
return val ? '❌' : ''
}
function blur(val, noOverride) {
if (val) {
return noOverride ? '🚫' : '✋'
}
return ''
}
function alert(val) {
return val ? '🪧' : ''
}
export {}

@ -1,4 +1,3 @@
import './code/labels.mjs'
import './code/label-groups.mjs'
export {}

@ -1,5 +1,3 @@
import './docs/labels.mjs'
import './docs/post-moderation-behaviors.mjs'
import './docs/profile-moderation-behaviors.mjs'
export {}

@ -18,6 +18,7 @@ import {
AtpPersistSessionHandler,
AtpAgentOpts,
} from './types'
import { BSKY_MODSERVICE_DID } from './const'
const REFRESH_SESSION = 'com.atproto.server.refreshSession'
@ -29,6 +30,7 @@ export class AtpAgent {
service: URL
api: AtpServiceClient
session?: AtpSessionData
labelersHeader: string[] = [BSKY_MODSERVICE_DID]
/**
* The PDS URL, driven by the did doc. May be undefined.
@ -81,6 +83,15 @@ export class AtpAgent {
this._persistSession = handler
}
/**
* Configures the moderation services to be applied on requests.
* NOTE: this is called automatically by getPreferences() and the relevant moderation config
* methods in BskyAgent instances.
*/
configureLabelersHeader(labelerDids: string[]) {
this.labelersHeader = labelerDids
}
/**
* Create a new account and hydrate its session in this agent.
*/
@ -194,13 +205,22 @@ export class AtpAgent {
/**
* Internal helper to add authorization headers to requests.
*/
private _addAuthHeader(reqHeaders: Record<string, string>) {
private _addHeaders(reqHeaders: Record<string, string>) {
if (!reqHeaders.authorization && this.session?.accessJwt) {
return {
reqHeaders = {
...reqHeaders,
authorization: `Bearer ${this.session.accessJwt}`,
}
}
if (this.labelersHeader.length) {
reqHeaders = {
...reqHeaders,
'atproto-labelers': this.labelersHeader
.filter((str) => str.startsWith('did:'))
.slice(0, 10)
.join(','),
}
}
return reqHeaders
}
@ -224,7 +244,7 @@ export class AtpAgent {
let res = await AtpAgent.fetch(
reqUri,
reqMethod,
this._addAuthHeader(reqHeaders),
this._addHeaders(reqHeaders),
reqBody,
)
@ -237,7 +257,7 @@ export class AtpAgent {
res = await AtpAgent.fetch(
reqUri,
reqMethod,
this._addAuthHeader(reqHeaders),
this._addHeaders(reqHeaders),
reqBody,
)
}

@ -1,4 +1,4 @@
import { AtUri } from '@atproto/syntax'
import { AtUri, ensureValidDid } from '@atproto/syntax'
import { AtpAgent } from './agent'
import {
AppBskyFeedPost,
@ -8,11 +8,13 @@ import {
} from './client'
import {
BskyPreferences,
BskyLabelPreference,
BskyFeedViewPreference,
BskyThreadViewPreference,
BskyInterestsPreference,
} from './types'
import { LabelPreference } from './moderation/types'
import { BSKY_MODSERVICE_DID } from './const'
import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels'
import { sanitizeMutedWordValue } from './util'
const FEED_VIEW_PREF_DEFAULTS = {
@ -98,6 +100,9 @@ export class BskyAgent extends AtpAgent {
(params, opts) =>
this.api.app.bsky.notification.getUnreadCount(params, opts)
getLabelers: typeof this.api.app.bsky.labeler.getServices = (params, opts) =>
this.api.app.bsky.labeler.getServices(params, opts)
async post(
record: Partial<AppBskyFeedPost.Record> &
Omit<AppBskyFeedPost.Record, 'createdAt'>,
@ -322,8 +327,11 @@ export class BskyAgent extends AtpAgent {
},
},
threadViewPrefs: { ...THREAD_VIEW_PREF_DEFAULTS },
adultContentEnabled: false,
contentLabels: {},
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS },
mods: [],
},
birthDate: undefined,
interests: {
tags: [],
@ -332,33 +340,42 @@ export class BskyAgent extends AtpAgent {
hiddenPosts: [],
}
const res = await this.app.bsky.actor.getPreferences({})
const labelPrefs: AppBskyActorDefs.ContentLabelPref[] = []
for (const pref of res.data.preferences) {
if (
AppBskyActorDefs.isAdultContentPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success
) {
prefs.adultContentEnabled = pref.enabled
// adult content preferences
prefs.moderationPrefs.adultContentEnabled = pref.enabled
} else if (
AppBskyActorDefs.isContentLabelPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success
AppBskyActorDefs.validateContentLabelPref(pref).success
) {
let value = pref.visibility
if (value === 'show') {
value = 'ignore'
}
if (value === 'ignore' || value === 'warn' || value === 'hide') {
prefs.contentLabels[pref.label] = value as BskyLabelPreference
}
// content label preference
const adjustedPref = adjustLegacyContentLabelPref(pref)
labelPrefs.push(adjustedPref)
} else if (
AppBskyActorDefs.isModsPref(pref) &&
AppBskyActorDefs.validateModsPref(pref).success
) {
// mods preferences
prefs.moderationPrefs.mods = pref.mods.map((mod) => ({
...mod,
labels: {},
}))
} else if (
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success
) {
// saved and pinned feeds
prefs.feeds.saved = pref.saved
prefs.feeds.pinned = pref.pinned
} else if (
AppBskyActorDefs.isPersonalDetailsPref(pref) &&
AppBskyActorDefs.validatePersonalDetailsPref(pref).success
) {
// birth date (irl)
if (pref.birthDate) {
prefs.birthDate = new Date(pref.birthDate)
}
@ -366,6 +383,7 @@ export class BskyAgent extends AtpAgent {
AppBskyActorDefs.isFeedViewPref(pref) &&
AppBskyActorDefs.validateFeedViewPref(pref).success
) {
// feed view preferences
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, feed, ...v } = pref
prefs.feedViewPrefs[pref.feed] = { ...FEED_VIEW_PREF_DEFAULTS, ...v }
@ -373,6 +391,7 @@ export class BskyAgent extends AtpAgent {
AppBskyActorDefs.isThreadViewPref(pref) &&
AppBskyActorDefs.validateThreadViewPref(pref).success
) {
// thread view preferences
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
prefs.threadViewPrefs = { ...prefs.threadViewPrefs, ...v }
@ -399,6 +418,35 @@ export class BskyAgent extends AtpAgent {
prefs.hiddenPosts = v.items
}
}
// ensure the bluesky moderation is configured
const bskyModeration = prefs.moderationPrefs.mods.find(
(modPref) => modPref.did === BSKY_MODSERVICE_DID,
)
if (!bskyModeration) {
prefs.moderationPrefs.mods.unshift({
did: BSKY_MODSERVICE_DID,
labels: {},
})
}
// apply the label prefs
for (const pref of labelPrefs) {
if (pref.labelerDid) {
const mod = prefs.moderationPrefs.mods.find(
(mod) => mod.did === pref.labelerDid,
)
if (!mod) continue
mod.labels[pref.label] = pref.visibility as LabelPreference
} else {
prefs.moderationPrefs.labels[pref.label] =
pref.visibility as LabelPreference
}
}
// automatically configure the client
this.configureLabelersHeader(prefsArrayToLabelerDids(res.data.preferences))
return prefs
}
@ -458,18 +506,21 @@ export class BskyAgent extends AtpAgent {
})
}
async setContentLabelPref(key: string, value: BskyLabelPreference) {
// TEMP update old value
if (value === 'show') {
value = 'ignore'
async setContentLabelPref(
key: string,
value: LabelPreference,
labelerDid?: string,
) {
if (labelerDid) {
ensureValidDid(labelerDid)
}
await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
let labelPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isContentLabelPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success &&
pref.label === key,
AppBskyActorDefs.validateContentLabelPref(pref).success &&
pref.label === key &&
pref.labelerDid === labelerDid,
)
if (labelPref) {
labelPref.visibility = value
@ -477,18 +528,80 @@ export class BskyAgent extends AtpAgent {
labelPref = {
$type: 'app.bsky.actor.defs#contentLabelPref',
label: key,
labelerDid,
visibility: value,
}
}
return prefs
.filter(
(pref) =>
!AppBskyActorDefs.isContentLabelPref(pref) || pref.label !== key,
!AppBskyActorDefs.isContentLabelPref(pref) ||
!(pref.label === key && pref.labelerDid === labelerDid),
)
.concat([labelPref])
})
}
async addModService(did: string) {
const prefs = await updatePreferences(
this,
(prefs: AppBskyActorDefs.Preferences) => {
let modsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isModsPref(pref) &&
AppBskyActorDefs.validateModsPref(pref).success,
)
if (!modsPref) {
modsPref = {
$type: 'app.bsky.actor.defs#modsPref',
mods: [],
}
}
if (AppBskyActorDefs.isModsPref(modsPref)) {
let modPrefItem = modsPref.mods.find((mod) => mod.did === did)
if (!modPrefItem) {
modPrefItem = {
did,
}
modsPref.mods.push(modPrefItem)
}
}
return prefs
.filter((pref) => !AppBskyActorDefs.isModsPref(pref))
.concat([modsPref])
},
)
// automatically configure the client
this.configureLabelersHeader(prefsArrayToLabelerDids(prefs))
}
async removeModService(did: string) {
const prefs = await updatePreferences(
this,
(prefs: AppBskyActorDefs.Preferences) => {
let modsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isModsPref(pref) &&
AppBskyActorDefs.validateModsPref(pref).success,
)
if (!modsPref) {
modsPref = {
$type: 'app.bsky.actor.defs#modsPref',
mods: [],
}
}
if (AppBskyActorDefs.isModsPref(modsPref)) {
modsPref.mods = modsPref.mods.filter((mod) => mod.did !== did)
}
return prefs
.filter((pref) => !AppBskyActorDefs.isModsPref(pref))
.concat([modsPref])
},
)
// automatically configure the client
this.configureLabelersHeader(prefsArrayToLabelerDids(prefs))
}
async setPersonalDetails({
birthDate,
}: {
@ -621,7 +734,7 @@ export class BskyAgent extends AtpAgent {
async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
let mutedWordsPref = prefs.findLast(
const mutedWordsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isMutedWordsPref(pref) &&
AppBskyActorDefs.validateMutedWordsPref(pref).success,
@ -646,7 +759,7 @@ export class BskyAgent extends AtpAgent {
async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
let mutedWordsPref = prefs.findLast(
const mutedWordsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isMutedWordsPref(pref) &&
AppBskyActorDefs.validateMutedWordsPref(pref).success,
@ -696,11 +809,12 @@ async function updatePreferences(
const res = await agent.app.bsky.actor.getPreferences({})
const newPrefs = cb(res.data.preferences)
if (newPrefs === false) {
return
return res.data.preferences
}
await agent.app.bsky.actor.putPreferences({
preferences: newPrefs,
})
return newPrefs
}
/**
@ -739,6 +853,52 @@ async function updateFeedPreferences(
return res
}
/**
* Helper to transform the legacy content preferences.
*/
function adjustLegacyContentLabelPref(
pref: AppBskyActorDefs.ContentLabelPref,
): AppBskyActorDefs.ContentLabelPref {
let label = pref.label
let visibility = pref.visibility
// adjust legacy values
if (visibility === 'show') {
visibility = 'ignore'
}
// adjust legacy labels
if (label === 'nsfw') {
label = 'porn'
}
if (label === 'suggestive') {
label = 'sexual'
}
return { ...pref, label, visibility }
}
/**
* A helper to get the currently enabled labelers from the full preferences array
*/
function prefsArrayToLabelerDids(
prefs: AppBskyActorDefs.Preferences,
): string[] {
const modsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isModsPref(pref) &&
AppBskyActorDefs.validateModsPref(pref).success,
)
let dids: string[] = []
if (modsPref) {
dids = (modsPref as AppBskyActorDefs.ModsPref).mods.map((mod) => mod.did)
}
if (!dids.includes(BSKY_MODSERVICE_DID)) {
dids.unshift(BSKY_MODSERVICE_DID)
}
return dids
}
async function updateHiddenPost(
agent: BskyAgent,
postUri: string,

@ -150,6 +150,9 @@ import * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor'
import * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList'
import * as AppBskyGraphUnmuteActor from './types/app/bsky/graph/unmuteActor'
import * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActorList'
import * as AppBskyLabelerDefs from './types/app/bsky/labeler/defs'
import * as AppBskyLabelerGetServices from './types/app/bsky/labeler/getServices'
import * as AppBskyLabelerService from './types/app/bsky/labeler/service'
import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/registerPush'
@ -304,6 +307,9 @@ export * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor'
export * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList'
export * as AppBskyGraphUnmuteActor from './types/app/bsky/graph/unmuteActor'
export * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActorList'
export * as AppBskyLabelerDefs from './types/app/bsky/labeler/defs'
export * as AppBskyLabelerGetServices from './types/app/bsky/labeler/getServices'
export * as AppBskyLabelerService from './types/app/bsky/labeler/service'
export * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
export * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
export * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/registerPush'
@ -1405,6 +1411,7 @@ export class AppBskyNS {
embed: AppBskyEmbedNS
feed: AppBskyFeedNS
graph: AppBskyGraphNS
labeler: AppBskyLabelerNS
notification: AppBskyNotificationNS
richtext: AppBskyRichtextNS
unspecced: AppBskyUnspeccedNS
@ -1415,6 +1422,7 @@ export class AppBskyNS {
this.embed = new AppBskyEmbedNS(service)
this.feed = new AppBskyFeedNS(service)
this.graph = new AppBskyGraphNS(service)
this.labeler = new AppBskyLabelerNS(service)
this.notification = new AppBskyNotificationNS(service)
this.richtext = new AppBskyRichtextNS(service)
this.unspecced = new AppBskyUnspeccedNS(service)
@ -2566,6 +2574,97 @@ export class ListitemRecord {
}
}
export class AppBskyLabelerNS {
_service: AtpServiceClient
service: ServiceRecord
constructor(service: AtpServiceClient) {
this._service = service
this.service = new ServiceRecord(service)
}
getServices(
params?: AppBskyLabelerGetServices.QueryParams,
opts?: AppBskyLabelerGetServices.CallOptions,
): Promise<AppBskyLabelerGetServices.Response> {
return this._service.xrpc
.call('app.bsky.labeler.getServices', params, undefined, opts)
.catch((e) => {
throw AppBskyLabelerGetServices.toKnownErr(e)
})
}
}
export class ServiceRecord {
_service: AtpServiceClient
constructor(service: AtpServiceClient) {
this._service = service
}
async list(
params: Omit<ComAtprotoRepoListRecords.QueryParams, 'collection'>,
): Promise<{
cursor?: string
records: { uri: string; value: AppBskyLabelerService.Record }[]
}> {
const res = await this._service.xrpc.call('com.atproto.repo.listRecords', {
collection: 'app.bsky.labeler.service',
...params,
})
return res.data
}
async get(
params: Omit<ComAtprotoRepoGetRecord.QueryParams, 'collection'>,
): Promise<{
uri: string
cid: string
value: AppBskyLabelerService.Record
}> {
const res = await this._service.xrpc.call('com.atproto.repo.getRecord', {
collection: 'app.bsky.labeler.service',
...params,
})
return res.data
}
async create(
params: Omit<
ComAtprotoRepoCreateRecord.InputSchema,
'collection' | 'record'
>,
record: AppBskyLabelerService.Record,
headers?: Record<string, string>,
): Promise<{ uri: string; cid: string }> {
record.$type = 'app.bsky.labeler.service'
const res = await this._service.xrpc.call(
'com.atproto.repo.createRecord',
undefined,
{
collection: 'app.bsky.labeler.service',
rkey: 'self',
...params,
record,
},
{ encoding: 'application/json', headers },
)
return res.data
}
async delete(
params: Omit<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>,
headers?: Record<string, string>,
): Promise<void> {
await this._service.xrpc.call(
'com.atproto.repo.deleteRecord',
undefined,
{ collection: 'app.bsky.labeler.service', ...params },
{ headers },
)
}
}
export class AppBskyNotificationNS {
_service: AtpServiceClient

@ -2267,6 +2267,83 @@ export const schemaDict = {
},
},
},
labelValueDefinition: {
type: 'object',
description:
'Declares a label value and its expected interpertations and behaviors.',
required: ['identifier', 'severity', 'blurs', 'locales'],
properties: {
identifier: {
type: 'string',
description:
"The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
maxLength: 100,
maxGraphemes: 100,
},
severity: {
type: 'string',
description:
"How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
knownValues: ['inform', 'alert', 'none'],
},
blurs: {
type: 'string',
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'],
},
locales: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings',
},
},
},
},
labelValueDefinitionStrings: {
type: 'object',
description:
'Strings which describe the label in the UI, localized into a specific language.',
required: ['lang', 'name', 'description'],
properties: {
lang: {
type: 'string',
description:
'The code of the language these strings are written in.',
format: 'language',
},
name: {
type: 'string',
description: 'A short human-readable name for the label.',
maxGraphemes: 64,
maxLength: 640,
},
description: {
type: 'string',
description:
'A longer description of what the label means and why it might be applied.',
maxGraphemes: 10000,
maxLength: 100000,
},
},
},
labelValue: {
type: 'string',
knownValues: [
'!hide',
'!no-promote',
'!warn',
'!no-unauthenticated',
'dmca-violation',
'doxxing',
'porn',
'sexual',
'nudity',
'nsfl',
'gore',
],
},
},
},
ComAtprotoLabelQueryLabels: {
@ -5050,6 +5127,10 @@ export const schemaDict = {
postsCount: {
type: 'integer',
},
associated: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#profileAssociated',
},
indexedAt: {
type: 'string',
format: 'datetime',
@ -5067,6 +5148,20 @@ export const schemaDict = {
},
},
},
profileAssociated: {
type: 'object',
properties: {
lists: {
type: 'integer',
},
feedgens: {
type: 'integer',
},
labeler: {
type: 'boolean',
},
},
},
viewerState: {
type: 'object',
description:
@ -5131,12 +5226,18 @@ export const schemaDict = {
type: 'object',
required: ['label', 'visibility'],
properties: {
labelerDid: {
type: 'string',
description:
'Which labeler does this preference apply to? If undefined, applies globally.',
format: 'did',
},
label: {
type: 'string',
},
visibility: {
type: 'string',
knownValues: ['show', 'warn', 'hide'],
knownValues: ['ignore', 'show', 'warn', 'hide'],
},
},
},
@ -5294,6 +5395,29 @@ export const schemaDict = {
},
},
},
modsPref: {
type: 'object',
required: ['mods'],
properties: {
mods: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#modPrefItem',
},
},
},
},
modPrefItem: {
type: 'object',
required: ['did'],
properties: {
did: {
type: 'string',
format: 'did',
},
},
},
},
},
AppBskyActorGetPreferences: {
@ -5798,6 +5922,7 @@ export const schemaDict = {
'lex:app.bsky.embed.record#viewBlocked',
'lex:app.bsky.feed.defs#generatorView',
'lex:app.bsky.graph.defs#listView',
'lex:app.bsky.labeler.defs#labelerView',
],
},
},
@ -8339,6 +8464,198 @@ export const schemaDict = {
},
},
},
AppBskyLabelerDefs: {
lexicon: 1,
id: 'app.bsky.labeler.defs',
defs: {
labelerView: {
type: 'object',
required: ['uri', 'cid', 'creator', 'indexedAt'],
properties: {
uri: {
type: 'string',
format: 'at-uri',
},
cid: {
type: 'string',
format: 'cid',
},
creator: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#profileView',
},
likeCount: {
type: 'integer',
minimum: 0,
},
viewer: {
type: 'ref',
ref: 'lex:app.bsky.labeler.defs#labelerViewerState',
},
indexedAt: {
type: 'string',
format: 'datetime',
},
labels: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#label',
},
},
},
},
labelerViewDetailed: {
type: 'object',
required: ['uri', 'cid', 'creator', 'policies', 'indexedAt'],
properties: {
uri: {
type: 'string',
format: 'at-uri',
},
cid: {
type: 'string',
format: 'cid',
},
creator: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#profileView',
},
policies: {
type: 'ref',
ref: 'lex:app.bsky.labeler.defs#labelerPolicies',
},
likeCount: {
type: 'integer',
minimum: 0,
},
viewer: {
type: 'ref',
ref: 'lex:app.bsky.labeler.defs#labelerViewerState',
},
indexedAt: {
type: 'string',
format: 'datetime',
},
labels: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#label',
},
},
},
},
labelerViewerState: {
type: 'object',
properties: {
like: {
type: 'string',
format: 'at-uri',
},
},
},
labelerPolicies: {
type: 'object',
required: ['labelValues'],
properties: {
labelValues: {
type: 'array',
description:
'The label values which this labeler publishes. May include global or custom labels.',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#labelValue',
},
},
labelValueDefinitions: {
type: 'array',
description:
'Label values created by this labeler and scoped exclusively to it. Labels defined here will override global label definitions for this labeler.',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#labelValueDefinition',
},
},
},
},
},
},
AppBskyLabelerGetServices: {
lexicon: 1,
id: 'app.bsky.labeler.getServices',
defs: {
main: {
type: 'query',
description: 'Get information about a list of labeler services.',
parameters: {
type: 'params',
required: ['dids'],
properties: {
dids: {
type: 'array',
items: {
type: 'string',
format: 'did',
},
},
detailed: {
type: 'boolean',
default: false,
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['views'],
properties: {
views: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.labeler.defs#labelerView',
'lex:app.bsky.labeler.defs#labelerViewDetailed',
],
},
},
},
},
},
},
},
},
AppBskyLabelerService: {
lexicon: 1,
id: 'app.bsky.labeler.service',
defs: {
main: {
type: 'record',
description: 'A declaration of the existence of labeler service.',
key: 'literal:self',
record: {
type: 'object',
required: ['policies', 'createdAt'],
properties: {
policies: {
type: 'ref',
ref: 'lex:app.bsky.labeler.defs#labelerPolicies',
},
labels: {
type: 'union',
refs: ['lex:com.atproto.label.defs#selfLabels'],
},
createdAt: {
type: 'string',
format: 'datetime',
},
},
},
},
},
},
AppBskyNotificationGetUnreadCount: {
lexicon: 1,
id: 'app.bsky.notification.getUnreadCount',
@ -9032,6 +9349,9 @@ export const ids = {
AppBskyGraphMuteActorList: 'app.bsky.graph.muteActorList',
AppBskyGraphUnmuteActor: 'app.bsky.graph.unmuteActor',
AppBskyGraphUnmuteActorList: 'app.bsky.graph.unmuteActorList',
AppBskyLabelerDefs: 'app.bsky.labeler.defs',
AppBskyLabelerGetServices: 'app.bsky.labeler.getServices',
AppBskyLabelerService: 'app.bsky.labeler.service',
AppBskyNotificationGetUnreadCount: 'app.bsky.notification.getUnreadCount',
AppBskyNotificationListNotifications:
'app.bsky.notification.listNotifications',

@ -64,6 +64,7 @@ export interface ProfileViewDetailed {
followersCount?: number
followsCount?: number
postsCount?: number
associated?: ProfileAssociated
indexedAt?: string
viewer?: ViewerState
labels?: ComAtprotoLabelDefs.Label[]
@ -82,6 +83,25 @@ export function validateProfileViewDetailed(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#profileViewDetailed', v)
}
export interface ProfileAssociated {
lists?: number
feedgens?: number
labeler?: boolean
[k: string]: unknown
}
export function isProfileAssociated(v: unknown): v is ProfileAssociated {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#profileAssociated'
)
}
export function validateProfileAssociated(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#profileAssociated', v)
}
/** Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests. */
export interface ViewerState {
muted?: boolean
@ -137,8 +157,10 @@ export function validateAdultContentPref(v: unknown): ValidationResult {
}
export interface ContentLabelPref {
/** Which labeler does this preference apply to? If undefined, applies globally. */
labelerDid?: string
label: string
visibility: 'show' | 'warn' | 'hide' | (string & {})
visibility: 'ignore' | 'show' | 'warn' | 'hide' | (string & {})
[k: string]: unknown
}
@ -315,3 +337,37 @@ export function isHiddenPostsPref(v: unknown): v is HiddenPostsPref {
export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
export interface ModsPref {
mods: ModPrefItem[]
[k: string]: unknown
}
export function isModsPref(v: unknown): v is ModsPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#modsPref'
)
}
export function validateModsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#modsPref', v)
}
export interface ModPrefItem {
did: string
[k: string]: unknown
}
export function isModPrefItem(v: unknown): v is ModPrefItem {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#modPrefItem'
)
}
export function validateModPrefItem(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#modPrefItem', v)
}

@ -8,6 +8,7 @@ import { CID } from 'multiformats/cid'
import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef'
import * as AppBskyFeedDefs from '../feed/defs'
import * as AppBskyGraphDefs from '../graph/defs'
import * as AppBskyLabelerDefs from '../labeler/defs'
import * as AppBskyActorDefs from '../actor/defs'
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
import * as AppBskyEmbedImages from './images'
@ -39,6 +40,7 @@ export interface View {
| ViewBlocked
| AppBskyFeedDefs.GeneratorView
| AppBskyGraphDefs.ListView
| AppBskyLabelerDefs.LabelerView
| { $type: string; [k: string]: unknown }
[k: string]: unknown
}

@ -0,0 +1,93 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as AppBskyActorDefs from '../actor/defs'
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
export interface LabelerView {
uri: string
cid: string
creator: AppBskyActorDefs.ProfileView
likeCount?: number
viewer?: LabelerViewerState
indexedAt: string
labels?: ComAtprotoLabelDefs.Label[]
[k: string]: unknown
}
export function isLabelerView(v: unknown): v is LabelerView {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.labeler.defs#labelerView'
)
}
export function validateLabelerView(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.labeler.defs#labelerView', v)
}
export interface LabelerViewDetailed {
uri: string
cid: string
creator: AppBskyActorDefs.ProfileView
policies: LabelerPolicies
likeCount?: number
viewer?: LabelerViewerState
indexedAt: string
labels?: ComAtprotoLabelDefs.Label[]
[k: string]: unknown
}
export function isLabelerViewDetailed(v: unknown): v is LabelerViewDetailed {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.labeler.defs#labelerViewDetailed'
)
}
export function validateLabelerViewDetailed(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.labeler.defs#labelerViewDetailed', v)
}
export interface LabelerViewerState {
like?: string
[k: string]: unknown
}
export function isLabelerViewerState(v: unknown): v is LabelerViewerState {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.labeler.defs#labelerViewerState'
)
}
export function validateLabelerViewerState(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.labeler.defs#labelerViewerState', v)
}
export interface LabelerPolicies {
/** The label values which this labeler publishes. May include global or custom labels. */
labelValues: ComAtprotoLabelDefs.LabelValue[]
/** Label values created by this labeler and scoped exclusively to it. Labels defined here will override global label definitions for this labeler. */
labelValueDefinitions?: ComAtprotoLabelDefs.LabelValueDefinition[]
[k: string]: unknown
}
export function isLabelerPolicies(v: unknown): v is LabelerPolicies {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.labeler.defs#labelerPolicies'
)
}
export function validateLabelerPolicies(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.labeler.defs#labelerPolicies', v)
}

@ -0,0 +1,41 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as AppBskyLabelerDefs from './defs'
export interface QueryParams {
dids: string[]
detailed?: boolean
}
export type InputSchema = undefined
export interface OutputSchema {
views: (
| AppBskyLabelerDefs.LabelerView
| AppBskyLabelerDefs.LabelerViewDetailed
| { $type: string; [k: string]: unknown }
)[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -0,0 +1,31 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as AppBskyLabelerDefs from './defs'
import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
export interface Record {
policies: AppBskyLabelerDefs.LabelerPolicies
labels?:
| ComAtprotoLabelDefs.SelfLabels
| { $type: string; [k: string]: unknown }
createdAt: string
[k: string]: unknown
}
export function isRecord(v: unknown): v is Record {
return (
isObj(v) &&
hasProp(v, '$type') &&
(v.$type === 'app.bsky.labeler.service#main' ||
v.$type === 'app.bsky.labeler.service')
)
}
export function validateRecord(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.labeler.service#main', v)
}

@ -71,3 +71,71 @@ export function isSelfLabel(v: unknown): v is SelfLabel {
export function validateSelfLabel(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.label.defs#selfLabel', v)
}
/** Declares a label value and its expected interpertations and behaviors. */
export interface LabelValueDefinition {
/** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */
identifier: string
/** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */
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 & {})
locales: LabelValueDefinitionStrings[]
[k: string]: unknown
}
export function isLabelValueDefinition(v: unknown): v is LabelValueDefinition {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.label.defs#labelValueDefinition'
)
}
export function validateLabelValueDefinition(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.label.defs#labelValueDefinition', v)
}
/** Strings which describe the label in the UI, localized into a specific language. */
export interface LabelValueDefinitionStrings {
/** The code of the language these strings are written in. */
lang: string
/** A short human-readable name for the label. */
name: string
/** A longer description of what the label means and why it might be applied. */
description: string
[k: string]: unknown
}
export function isLabelValueDefinitionStrings(
v: unknown,
): v is LabelValueDefinitionStrings {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.label.defs#labelValueDefinitionStrings'
)
}
export function validateLabelValueDefinitionStrings(
v: unknown,
): ValidationResult {
return lexicons.validate(
'com.atproto.label.defs#labelValueDefinitionStrings',
v,
)
}
export type LabelValue =
| '!hide'
| '!no-promote'
| '!warn'
| '!no-unauthenticated'
| 'dmca-violation'
| 'doxxing'
| 'porn'
| 'sexual'
| 'nudity'
| 'nsfl'
| 'gore'
| (string & {})

@ -0,0 +1 @@
export const BSKY_MODSERVICE_DID = 'did:plc:ar7c4by46qjdydhdevvrndac'

@ -8,6 +8,7 @@ export {
} from '@atproto/lexicon'
export { parseLanguage } from '@atproto/common-web'
export * from './types'
export * from './const'
export * from './util'
export * from './client'
export * from './agent'
@ -17,7 +18,7 @@ export * from './rich-text/unicode'
export * from './rich-text/util'
export * from './moderation'
export * from './moderation/types'
export { LABELS } from './moderation/const/labels'
export { LABEL_GROUPS } from './moderation/const/label-groups'
export * from './mocker'
export { LABELS, DEFAULT_LABEL_SETTINGS } from './moderation/const/labels'
export { BskyAgent } from './bsky-agent'
export { AtpAgent as default } from './agent'

214
packages/api/src/mocker.ts Normal file

@ -0,0 +1,214 @@
import {
ComAtprotoLabelDefs,
AppBskyFeedDefs,
AppBskyActorDefs,
AppBskyFeedPost,
AppBskyEmbedRecord,
AppBskyGraphDefs,
AppBskyNotificationListNotifications,
} from './client'
const FAKE_CID = 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq'
export const mock = {
post({
text,
reply,
embed,
}: {
text: string
reply?: AppBskyFeedPost.ReplyRef
embed?: AppBskyFeedPost.Record['embed']
}): AppBskyFeedPost.Record {
return {
$type: 'app.bsky.feed.post',
text,
reply,
embed,
langs: ['en'],
createdAt: new Date().toISOString(),
}
},
postView({
record,
author,
embed,
replyCount,
repostCount,
likeCount,
viewer,
labels,
}: {
record: AppBskyFeedPost.Record
author: AppBskyActorDefs.ProfileViewBasic
embed?: AppBskyFeedDefs.PostView['embed']
replyCount?: number
repostCount?: number
likeCount?: number
viewer?: AppBskyFeedDefs.ViewerState
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyFeedDefs.PostView {
return {
uri: `at://${author.did}/app.bsky.feed.post/fake`,
cid: FAKE_CID,
author,
record,
embed,
replyCount,
repostCount,
likeCount,
indexedAt: new Date().toISOString(),
viewer,
labels,
}
},
embedRecordView({
record,
author,
labels,
}: {
record: AppBskyFeedPost.Record
author: AppBskyActorDefs.ProfileViewBasic
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyEmbedRecord.View {
return {
$type: 'app.bsky.embed.record#view',
record: {
$type: 'app.bsky.embed.record#viewRecord',
uri: `at://${author.did}/app.bsky.feed.post/fake`,
cid: FAKE_CID,
author,
value: record,
labels,
indexedAt: new Date().toISOString(),
},
}
},
profileViewBasic({
handle,
displayName,
description,
viewer,
labels,
}: {
handle: string
displayName?: string
description?: string
viewer?: AppBskyActorDefs.ViewerState
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyActorDefs.ProfileViewBasic {
return {
did: `did:web:${handle}`,
handle,
displayName,
description, // technically not in ProfileViewBasic but useful in some cases
viewer,
labels,
}
},
actorViewerState({
muted,
mutedByList,
blockedBy,
blocking,
blockingByList,
following,
followedBy,
}: {
muted?: boolean
mutedByList?: AppBskyGraphDefs.ListViewBasic
blockedBy?: boolean
blocking?: string
blockingByList?: AppBskyGraphDefs.ListViewBasic
following?: string
followedBy?: string
}): AppBskyActorDefs.ViewerState {
return {
muted,
mutedByList,
blockedBy,
blocking,
blockingByList,
following,
followedBy,
}
},
listViewBasic({ name }: { name: string }): AppBskyGraphDefs.ListViewBasic {
return {
uri: 'at://did:plc:fake/app.bsky.graph.list/fake',
cid: FAKE_CID,
name,
purpose: 'app.bsky.graph.defs#modlist',
indexedAt: new Date().toISOString(),
}
},
replyNotification({
author,
record,
labels,
}: {
record: AppBskyFeedPost.Record
author: AppBskyActorDefs.ProfileViewBasic
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyNotificationListNotifications.Notification {
return {
uri: `at://${author.did}/app.bsky.feed.post/fake`,
cid: FAKE_CID,
author,
reason: 'reply',
reasonSubject: `at://${author.did}/app.bsky.feed.post/fake-parent`,
record,
isRead: false,
indexedAt: new Date().toISOString(),
labels,
}
},
followNotification({
author,
subjectDid,
labels,
}: {
author: AppBskyActorDefs.ProfileViewBasic
subjectDid: string
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyNotificationListNotifications.Notification {
return {
uri: `at://${author.did}/app.bsky.graph.follow/fake`,
cid: FAKE_CID,
author,
reason: 'follow',
record: {
$type: 'app.bsky.graph.follow',
createdAt: new Date().toISOString(),
subject: subjectDid,
},
isRead: false,
indexedAt: new Date().toISOString(),
labels,
}
},
label({
val,
uri,
src,
}: {
val: string
uri: string
src?: string
}): ComAtprotoLabelDefs.Label {
return {
src: src || 'did:plc:fake-labeler',
uri,
val,
cts: new Date().toISOString(),
}
},
}

@ -1,217 +0,0 @@
import { AppBskyGraphDefs } from '../client/index'
import {
Label,
LabelPreference,
ModerationCause,
ModerationOpts,
ModerationDecision,
} from './types'
import { LABELS } from './const/labels'
export class ModerationCauseAccumulator {
did = ''
causes: ModerationCause[] = []
constructor() {}
setDid(did: string) {
this.did = did
}
addBlocking(blocking: string | undefined) {
if (blocking) {
this.causes.push({
type: 'blocking',
source: { type: 'user' },
priority: 3,
})
}
}
addBlockingByList(
blockingByList: AppBskyGraphDefs.ListViewBasic | undefined,
) {
if (blockingByList) {
this.causes.push({
type: 'blocking',
source: { type: 'list', list: blockingByList },
priority: 3,
})
}
}
addBlockedBy(blockedBy: boolean | undefined) {
if (blockedBy) {
this.causes.push({
type: 'blocked-by',
source: { type: 'user' },
priority: 4,
})
}
}
addBlockOther(blockOther: boolean | undefined) {
if (blockOther) {
this.causes.push({
type: 'block-other',
source: { type: 'user' },
priority: 4,
})
}
}
addLabel(label: Label, opts: ModerationOpts) {
// look up the label definition
const labelDef = LABELS[label.val]
if (!labelDef) {
// ignore labels we don't understand
return
}
// look up the label preference
const isSelf = label.src === this.did
const labeler = isSelf
? undefined
: opts.labelers.find((s) => s.labeler.did === label.src)
/* TODO when 3P labelers are supported
if (!isSelf && !labeler) {
return // skip labelers not configured by the user
}*/
// establish the label preference for interpretation
let labelPref: LabelPreference = 'ignore'
if (!labelDef.configurable) {
labelPref = labelDef.preferences[0]
} else if (labelDef.flags.includes('adult') && !opts.adultContentEnabled) {
labelPref = 'hide'
} else if (labeler?.labels[label.val]) {
labelPref = labeler.labels[label.val]
} else if (opts.labels[label.val]) {
labelPref = opts.labels[label.val]
}
// ignore labels the user has asked to ignore
if (labelPref === 'ignore') {
return
}
// ignore 'unauthed' labels when the user is authed
if (labelDef.flags.includes('unauthed') && !!opts.userDid) {
return
}
// establish the priority of the label
let priority: 1 | 2 | 5 | 7 | 8
if (labelDef.flags.includes('no-override')) {
priority = 1
} else if (labelPref === 'hide') {
priority = 2
} else if (labelDef.onwarn === 'blur') {
priority = 5
} else if (labelDef.onwarn === 'blur-media') {
priority = 7
} else {
priority = 8
}
this.causes.push({
type: 'label',
source:
isSelf || !labeler
? { type: 'user' }
: { type: 'labeler', labeler: labeler.labeler },
label,
labelDef,
setting: labelPref,
priority,
})
}
addMuted(muted: boolean | undefined) {
if (muted) {
this.causes.push({
type: 'muted',
source: { type: 'user' },
priority: 6,
})
}
}
addMutedByList(mutedByList: AppBskyGraphDefs.ListViewBasic | undefined) {
if (mutedByList) {
this.causes.push({
type: 'muted',
source: { type: 'list', list: mutedByList },
priority: 6,
})
}
}
finalizeDecision(opts: ModerationOpts): ModerationDecision {
const mod = new ModerationDecision()
mod.did = this.did
if (!this.causes.length) {
return mod
}
// sort the causes by priority and then choose the top one
this.causes.sort((a, b) => a.priority - b.priority)
mod.cause = this.causes[0]
mod.additionalCauses = this.causes.slice(1)
// blocked user
if (
mod.cause.type === 'blocking' ||
mod.cause.type === 'blocked-by' ||
mod.cause.type === 'block-other'
) {
// filter and blur, dont allow override
mod.filter = true
mod.blur = true
mod.noOverride = true
}
// muted user
else if (mod.cause.type === 'muted') {
// filter and blur
mod.filter = true
mod.blur = true
}
// labeled subject
else if (mod.cause.type === 'label') {
// 'hide' setting
if (mod.cause.setting === 'hide') {
// filter
mod.filter = true
}
// 'hide' and 'warn' setting, apply onwarn
switch (mod.cause.labelDef.onwarn) {
case 'alert':
mod.alert = true
break
case 'blur':
mod.blur = true
break
case 'blur-media':
mod.blurMedia = true
break
case null:
// do nothing
break
}
// apply noOverride as needed
if (mod.cause.labelDef.flags.includes('no-override')) {
mod.noOverride = true
} else if (
mod.cause.labelDef.flags.includes('adult') &&
!opts.adultContentEnabled
) {
mod.noOverride = true
}
}
return mod
}
}

@ -1,149 +0,0 @@
/** this doc is generated by ./scripts/code/labels.mjs **/
import { LabelGroupDefinitionMap } from '../types'
import { LABELS } from './labels'
export const LABEL_GROUPS: LabelGroupDefinitionMap = {
system: {
id: 'system',
configurable: false,
labels: [
LABELS['!hide'],
LABELS['!no-promote'],
LABELS['!warn'],
LABELS['!no-unauthenticated'],
],
strings: {
settings: {
en: {
name: 'System',
description: 'Moderator overrides for special cases.',
},
},
},
},
legal: {
id: 'legal',
configurable: false,
labels: [LABELS['dmca-violation'], LABELS['doxxing']],
strings: {
settings: {
en: {
name: 'Legal',
description: 'Content removed for legal reasons.',
},
},
},
},
sexual: {
id: 'sexual',
configurable: true,
labels: [LABELS['porn'], LABELS['sexual'], LABELS['nudity']],
strings: {
settings: {
en: {
name: 'Adult Content',
description: 'Content which is sexual in nature.',
},
},
},
},
violence: {
id: 'violence',
configurable: true,
labels: [
LABELS['nsfl'],
LABELS['corpse'],
LABELS['gore'],
LABELS['torture'],
LABELS['self-harm'],
],
strings: {
settings: {
en: {
name: 'Violence',
description: 'Content which is violent or deeply disturbing.',
},
},
},
},
intolerance: {
id: 'intolerance',
configurable: true,
labels: [
LABELS['intolerant-race'],
LABELS['intolerant-gender'],
LABELS['intolerant-sexual-orientation'],
LABELS['intolerant-religion'],
LABELS['intolerant'],
LABELS['icon-intolerant'],
],
strings: {
settings: {
en: {
name: 'Intolerance',
description:
'Content or behavior which is hateful or intolerant toward a group of people.',
},
},
},
},
rude: {
id: 'rude',
configurable: true,
labels: [LABELS['threat']],
strings: {
settings: {
en: {
name: 'Rude',
description: 'Behavior which is rude toward other users.',
},
},
},
},
curation: {
id: 'curation',
configurable: true,
labels: [LABELS['spoiler']],
strings: {
settings: {
en: {
name: 'Curational',
description:
'Subjective moderation geared towards curating a more positive environment.',
},
},
},
},
spam: {
id: 'spam',
configurable: true,
labels: [LABELS['spam']],
strings: {
settings: {
en: {
name: 'Spam',
description: "Content which doesn't add to the conversation.",
},
},
},
},
misinfo: {
id: 'misinfo',
configurable: true,
labels: [
LABELS['account-security'],
LABELS['net-abuse'],
LABELS['impersonation'],
LABELS['scam'],
LABELS['misleading'],
],
strings: {
settings: {
en: {
name: 'Misinformation',
description: 'Content which misleads or defrauds users.',
},
},
},
},
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,337 @@
import { AppBskyGraphDefs } from '../client/index'
import {
BLOCK_BEHAVIOR,
MUTE_BEHAVIOR,
HIDE_BEHAVIOR,
NOOP_BEHAVIOR,
Label,
LabelPreference,
ModerationCause,
ModerationOpts,
InterprettedLabelValueDefinition,
LabelTarget,
ModerationBehavior,
CUSTOM_LABEL_VALUE_RE,
} from './types'
import { ModerationUI } from './ui'
import { LABELS } from './const/labels'
enum ModerationBehaviorSeverity {
High,
Medium,
Low,
}
export class ModerationDecision {
did = ''
isMe = false
causes: ModerationCause[] = []
constructor() {}
static merge(
...decisions: (ModerationDecision | undefined)[]
): ModerationDecision {
const firmDecisions: ModerationDecision[] = decisions.filter(
(v) => !!v,
) as ModerationDecision[]
const decision = new ModerationDecision()
if (firmDecisions[0]) {
decision.did = firmDecisions[0].did
decision.isMe = firmDecisions[0].isMe
}
decision.causes = firmDecisions.flatMap((d) => d.causes)
return decision
}
get blocked() {
return !!this.blockCause
}
get muted() {
return !!this.muteCause
}
get blockCause() {
return this.causes.find(
(cause) =>
cause.type === 'blocking' ||
cause.type === 'blocked-by' ||
cause.type === 'block-other',
)
}
get muteCause() {
return this.causes.find((cause) => cause.type === 'muted')
}
get labelCauses() {
return this.causes.filter((cause) => cause.type === 'label')
}
ui(context: keyof ModerationBehavior): ModerationUI {
const ui = new ModerationUI()
if (this.isMe) {
return ui
}
for (const cause of this.causes) {
if (
cause.type === 'blocking' ||
cause.type === 'blocked-by' ||
cause.type === 'block-other'
) {
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)
}
} 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)
}
} 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)
}
} else if (cause.type === 'label') {
if (context === 'profileList' || context === 'contentList') {
if (cause.setting === 'hide') {
ui.filters.push(cause)
}
}
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)
}
}
}
ui.filters.sort(sortByPriority)
ui.blurs.sort(sortByPriority)
return ui
}
setDid(did: string) {
this.did = did
}
setIsMe(isMe: boolean) {
this.isMe = isMe
}
addHidden(hidden: boolean) {
if (hidden) {
this.causes.push({
type: 'hidden',
source: { type: 'user' },
priority: 6,
})
}
}
addBlocking(blocking: string | undefined) {
if (blocking) {
this.causes.push({
type: 'blocking',
source: { type: 'user' },
priority: 3,
})
}
}
addBlockingByList(
blockingByList: AppBskyGraphDefs.ListViewBasic | undefined,
) {
if (blockingByList) {
this.causes.push({
type: 'blocking',
source: { type: 'list', list: blockingByList },
priority: 3,
})
}
}
addBlockedBy(blockedBy: boolean | undefined) {
if (blockedBy) {
this.causes.push({
type: 'blocked-by',
source: { type: 'user' },
priority: 4,
})
}
}
addBlockOther(blockOther: boolean | undefined) {
if (blockOther) {
this.causes.push({
type: 'block-other',
source: { type: 'user' },
priority: 4,
})
}
}
addLabel(target: LabelTarget, label: Label, opts: ModerationOpts) {
// look up the label definition
const labelDef = CUSTOM_LABEL_VALUE_RE.test(label.val)
? opts.labelDefs?.[label.src]?.find(
(def) => def.identifier === label.val,
) || LABELS[label.val]
: LABELS[label.val]
if (!labelDef) {
// ignore labels we don't understand
return
}
// look up the label preference
const isSelf = label.src === this.did
const labeler = isSelf
? undefined
: opts.prefs.mods.find((s) => s.did === label.src)
if (!isSelf && !labeler) {
return // skip labelers not configured by the user
}
if (isSelf && labelDef.flags.includes('no-self')) {
return // skip self-labels that arent supported
}
// establish the label preference for interpretation
let labelPref: LabelPreference = 'ignore'
if (!labelDef.configurable) {
labelPref = labelDef.defaultSetting || 'hide'
} else if (
labelDef.flags.includes('adult') &&
!opts.prefs.adultContentEnabled
) {
labelPref = 'hide'
} else if (labeler?.labels[labelDef.identifier]) {
labelPref = labeler?.labels[labelDef.identifier]
} else if (opts.prefs.labels[labelDef.identifier]) {
labelPref = opts.prefs.labels[labelDef.identifier]
}
// ignore labels the user has asked to ignore
if (labelPref === 'ignore') {
return
}
// ignore 'unauthed' labels when the user is authed
if (labelDef.flags.includes('unauthed') && !!opts.userDid) {
return
}
// establish the priority of the label
let priority: 1 | 2 | 5 | 7 | 8
const severity = measureModerationBehaviorSeverity(
labelDef.behaviors[target],
)
if (
labelDef.flags.includes('no-override') ||
(labelDef.flags.includes('adult') && !opts.prefs.adultContentEnabled)
) {
priority = 1
} else if (labelPref === 'hide') {
priority = 2
} else if (severity === ModerationBehaviorSeverity.High) {
// blurring profile view or content view
priority = 5
} else if (severity === ModerationBehaviorSeverity.Medium) {
// blurring content list or content media
priority = 7
} else {
// blurring avatar, adding alerts
priority = 8
}
let noOverride = false
if (labelDef.flags.includes('no-override')) {
noOverride = true
} else if (
labelDef.flags.includes('adult') &&
!opts.prefs.adultContentEnabled
) {
noOverride = true
}
this.causes.push({
type: 'label',
source:
isSelf || !labeler
? { type: 'user' }
: { type: 'labeler', did: labeler.did },
label,
labelDef,
setting: labelPref,
behavior: labelDef.behaviors[target] || NOOP_BEHAVIOR,
noOverride,
priority,
})
}
addMuted(muted: boolean | undefined) {
if (muted) {
this.causes.push({
type: 'muted',
source: { type: 'user' },
priority: 6,
})
}
}
addMutedByList(mutedByList: AppBskyGraphDefs.ListViewBasic | undefined) {
if (mutedByList) {
this.causes.push({
type: 'muted',
source: { type: 'list', list: mutedByList },
priority: 6,
})
}
}
}
function measureModerationBehaviorSeverity(
beh: ModerationBehavior | undefined,
): ModerationBehaviorSeverity {
if (!beh) {
return ModerationBehaviorSeverity.Low
}
if (beh.profileView === 'blur' || beh.contentView === 'blur') {
return ModerationBehaviorSeverity.High
}
if (beh.contentList === 'blur' || beh.contentMedia === 'blur') {
return ModerationBehaviorSeverity.Medium
}
return ModerationBehaviorSeverity.Low
}
function sortByPriority(a: ModerationCause, b: ModerationCause) {
return a.priority - b.priority
}

@ -2,345 +2,79 @@ import { AppBskyActorDefs } from '../client/index'
import {
ModerationSubjectProfile,
ModerationSubjectPost,
ModerationSubjectNotification,
ModerationSubjectFeedGenerator,
ModerationSubjectUserList,
ModerationOpts,
ModerationDecision,
ModerationUI,
} from './types'
import { decideAccount } from './subjects/account'
import { decideProfile } from './subjects/profile'
import { decideNotification } from './subjects/notification'
import { decidePost } from './subjects/post'
import {
decideQuotedPost,
decideQuotedPostAccount,
decideQuotedPostWithMedia,
decideQuotedPostWithMediaAccount,
} from './subjects/quoted-post'
import { decideFeedGenerator } from './subjects/feed-generator'
import { decideUserList } from './subjects/user-list'
import {
takeHighestPriorityDecision,
downgradeDecision,
isModerationDecisionNoop,
isQuotedPost,
isQuotedPostWithMedia,
toModerationUI,
import { ModerationDecision } from './decision'
export { ModerationUI } from './ui'
export { ModerationDecision } from './decision'
export {
interpretLabelValueDefinition,
interpretLabelValueDefinitions,
} from './util'
// profiles
// =
export interface ProfileModeration {
decisions: {
account: ModerationDecision
profile: ModerationDecision
}
account: ModerationUI
profile: ModerationUI
avatar: ModerationUI
}
export function moderateProfile(
subject: ModerationSubjectProfile,
opts: ModerationOpts,
): ProfileModeration {
// decide the moderation the account and the profile
const account = decideAccount(subject, opts)
const profile = decideProfile(subject, opts)
// if the decision is supposed to blur media,
// - have it apply to the view if it's on the account
// - otherwise ignore it
if (account.blurMedia) {
account.blur = true
}
// don't give profile.filter because that is meaningless
profile.filter = false
// downgrade based on authorship
if (!isModerationDecisionNoop(account) && account.did === opts.userDid) {
downgradeDecision(account, 'alert')
}
if (!isModerationDecisionNoop(profile) && profile.did === opts.userDid) {
downgradeDecision(profile, 'alert')
}
// derive avatar blurring from account & profile, but override for mutes because that shouldn't blur
let avatarBlur = false
let avatarNoOverride = false
if ((account.blur || account.blurMedia) && account.cause?.type !== 'muted') {
avatarBlur = true
avatarNoOverride = account.noOverride || profile.noOverride
} else if (profile.blur || profile.blurMedia) {
avatarBlur = true
avatarNoOverride = account.noOverride || profile.noOverride
}
// don't blur the account for blocking & muting
if (
account.cause?.type === 'blocking' ||
account.cause?.type === 'blocked-by' ||
account.cause?.type === 'muted'
) {
account.blur = false
account.noOverride = false
}
return {
decisions: { account, profile },
// moderate all content based on account
account:
account.filter || account.blur || account.alert
? toModerationUI(account)
: {},
// moderate the profile details based on the profile
profile:
profile.filter || profile.blur || profile.alert
? toModerationUI(profile)
: {},
// blur or alert the avatar based on the account and profile decisions
avatar: {
blur: avatarBlur,
alert: account.alert || profile.alert,
noOverride: avatarNoOverride,
},
}
}
// posts
// =
export interface PostModeration {
decisions: {
post: ModerationDecision
account: ModerationDecision
profile: ModerationDecision
quote?: ModerationDecision
quotedAccount?: ModerationDecision
}
content: ModerationUI
avatar: ModerationUI
embed: ModerationUI
): ModerationDecision {
return ModerationDecision.merge(
decideAccount(subject, opts),
decideProfile(subject, opts),
)
}
export function moderatePost(
subject: ModerationSubjectPost,
opts: ModerationOpts,
): PostModeration {
// decide the moderation for the post, the post author's account,
// and the post author's profile
const post = decidePost(subject, opts)
const account = decideAccount(subject.author, opts)
const profile = decideProfile(subject.author, opts)
// decide the moderation for any quoted posts
let quote: ModerationDecision | undefined
let quotedAccount: ModerationDecision | undefined
if (isQuotedPost(subject.embed)) {
quote = decideQuotedPost(subject.embed, opts)
quotedAccount = decideQuotedPostAccount(subject.embed, opts)
} else if (isQuotedPostWithMedia(subject.embed)) {
quote = decideQuotedPostWithMedia(subject.embed, opts)
quotedAccount = decideQuotedPostWithMediaAccount(subject.embed, opts)
}
if (quote?.blurMedia) {
quote.blur = true // treat blurMedia of quote as blur of quote
}
// downgrade based on authorship
if (!isModerationDecisionNoop(post) && post.did === opts.userDid) {
downgradeDecision(post, 'blur')
}
if (account.cause && account.did === opts.userDid) {
downgradeDecision(account, 'noop')
}
if (profile.cause && profile.did === opts.userDid) {
downgradeDecision(profile, 'noop')
}
if (quote && !isModerationDecisionNoop(quote) && quote.did === opts.userDid) {
downgradeDecision(quote, 'blur')
}
if (
quotedAccount &&
!isModerationDecisionNoop(quotedAccount) &&
quotedAccount.did === opts.userDid
) {
downgradeDecision(quotedAccount, 'noop')
}
// derive filtering from feeds from the post, post author's account,
// quoted post, and quoted post author's account
const mergedForFeed = takeHighestPriorityDecision(
post,
account,
quote,
quotedAccount,
): ModerationDecision {
return ModerationDecision.merge(
decidePost(subject, opts),
decideAccount(subject.author, opts),
decideProfile(subject.author, opts),
)
// derive view blurring from the post and the post author's account
const mergedForView = takeHighestPriorityDecision(post, account)
// derive embed blurring from the quoted post and the quoted post author's account
const mergedQuote = takeHighestPriorityDecision(quote, quotedAccount)
// derive avatar blurring from account & profile, but override for mutes because that shouldn't blur
let blurAvatar = false
if ((account.blur || account.blurMedia) && account.cause?.type !== 'muted') {
blurAvatar = true
} else if (
(profile.blur || profile.blurMedia) &&
profile.cause?.type !== 'muted'
) {
blurAvatar = true
}
return {
decisions: { post, account, profile, quote, quotedAccount },
// content behaviors are pulled from feed and view derivations above
content: {
cause: !isModerationDecisionNoop(mergedForView)
? mergedForView.cause
: mergedForFeed.filter
? mergedForFeed.cause
: undefined,
filter: mergedForFeed.filter,
blur: mergedForView.blur,
alert: mergedForView.alert,
noOverride: mergedForView.noOverride,
},
// blur or alert the avatar based on the account and profile decisions
avatar: {
blur: blurAvatar,
alert: account.alert || profile.alert,
noOverride: account.noOverride || profile.noOverride,
},
// blur the embed if the quoted post required it,
// or else if the account or post decision was to blur media
embed: !isModerationDecisionNoop(mergedQuote, { ignoreFilter: true })
? {
cause: mergedQuote.cause,
blur: mergedQuote.blur,
alert: mergedQuote.alert,
noOverride: mergedQuote.noOverride,
}
: account.blurMedia
? {
cause: account.cause,
blur: true,
noOverride: account.noOverride,
}
: post.blurMedia
? {
cause: post.cause,
blur: true,
noOverride: post.noOverride,
}
: {},
}
}
// feed generators
// =
export interface FeedGeneratorModeration {
decisions: {
feedGenerator: ModerationDecision
account: ModerationDecision
profile: ModerationDecision
}
content: ModerationUI
avatar: ModerationUI
export function moderateNotification(
subject: ModerationSubjectNotification,
opts: ModerationOpts,
): ModerationDecision {
return ModerationDecision.merge(
decideNotification(subject, opts),
decideAccount(subject.author, opts),
decideProfile(subject.author, opts),
)
}
export function moderateFeedGenerator(
subject: ModerationSubjectFeedGenerator,
opts: ModerationOpts,
): FeedGeneratorModeration {
// decide the moderation for the generator, the generator creator's account,
// and the generator creator's profile
const feedGenerator = decideFeedGenerator(subject, opts)
const account = decideAccount(subject.creator, opts)
const profile = decideProfile(subject.creator, opts)
// derive behaviors from feeds from the generator and the generator's account
const merged = takeHighestPriorityDecision(feedGenerator, account)
return {
decisions: { feedGenerator, account, profile },
// content behaviors are pulled from merged decisions
content: {
cause: isModerationDecisionNoop(merged) ? undefined : merged.cause,
filter: merged.filter,
blur: merged.blur,
alert: merged.alert,
noOverride: merged.noOverride,
},
// blur or alert the avatar based on the account and profile decisions
avatar: {
blur: account.blurMedia || profile.blurMedia,
alert: account.alert,
noOverride: account.noOverride || profile.noOverride,
},
}
}
// user lists
// =
export interface UserListModeration {
decisions: {
userList: ModerationDecision
account: ModerationDecision
profile: ModerationDecision
}
content: ModerationUI
avatar: ModerationUI
): ModerationDecision {
return ModerationDecision.merge(
decideFeedGenerator(subject, opts),
decideAccount(subject.creator, opts),
decideProfile(subject.creator, opts),
)
}
export function moderateUserList(
subject: ModerationSubjectUserList,
opts: ModerationOpts,
): UserListModeration {
// decide the moderation for the list, the list creator's account,
// and the list creator's profile
): ModerationDecision {
const userList = decideUserList(subject, opts)
const account = AppBskyActorDefs.isProfileViewBasic(subject.creator)
? decideAccount(subject.creator, opts)
: ModerationDecision.noop()
: new ModerationDecision()
const profile = AppBskyActorDefs.isProfileViewBasic(subject.creator)
? decideProfile(subject.creator, opts)
: ModerationDecision.noop()
// derive behaviors from feeds from the list and the list's account
const merged = takeHighestPriorityDecision(userList, account)
return {
decisions: { userList, account, profile },
// content behaviors are pulled from merged decisions
content: {
cause: isModerationDecisionNoop(merged) ? undefined : merged.cause,
filter: merged.filter,
blur: merged.blur,
alert: merged.alert,
noOverride: merged.noOverride,
},
// blur or alert the avatar based on the account and profile decisions
avatar: {
blur: account.blurMedia || profile.blurMedia,
alert: account.alert,
noOverride: account.noOverride || profile.noOverride,
},
}
: new ModerationDecision()
return ModerationDecision.merge(userList, account, profile)
}

@ -1,18 +1,14 @@
import { ModerationCauseAccumulator } from '../accumulator'
import {
Label,
ModerationSubjectProfile,
ModerationOpts,
ModerationDecision,
} from '../types'
import { ModerationDecision } from '../decision'
import { Label, ModerationSubjectProfile, ModerationOpts } from '../types'
export function decideAccount(
subject: ModerationSubjectProfile,
opts: ModerationOpts,
): ModerationDecision {
const acc = new ModerationCauseAccumulator()
const acc = new ModerationDecision()
acc.setDid(subject.did)
acc.setIsMe(subject.did === opts.userDid)
if (subject.viewer?.muted) {
if (subject.viewer?.mutedByList) {
acc.addMutedByList(subject.viewer?.mutedByList)
@ -30,10 +26,10 @@ export function decideAccount(
acc.addBlockedBy(subject.viewer?.blockedBy)
for (const label of filterAccountLabels(subject.labels)) {
acc.addLabel(label, opts)
acc.addLabel('account', label, opts)
}
return acc.finalizeDecision(opts)
return acc
}
export function filterAccountLabels(labels?: Label[]): Label[] {

@ -1,13 +1,10 @@
import {
ModerationSubjectFeedGenerator,
ModerationDecision,
ModerationOpts,
} from '../types'
import { ModerationDecision } from '../decision'
import { ModerationSubjectFeedGenerator, ModerationOpts } from '../types'
export function decideFeedGenerator(
_subject: ModerationSubjectFeedGenerator,
_opts: ModerationOpts,
): ModerationDecision {
// TODO handle labels applied on the feed generator itself
return ModerationDecision.noop()
return new ModerationDecision()
}

@ -0,0 +1,19 @@
import { ModerationDecision } from '../decision'
import { ModerationSubjectNotification, ModerationOpts } from '../types'
export function decideNotification(
subject: ModerationSubjectNotification,
opts: ModerationOpts,
): ModerationDecision {
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 acc
}

@ -1,23 +1,19 @@
import { ModerationCauseAccumulator } from '../accumulator'
import {
ModerationSubjectPost,
ModerationOpts,
ModerationDecision,
} from '../types'
import { ModerationDecision } from '../decision'
import { ModerationSubjectPost, ModerationOpts } from '../types'
export function decidePost(
subject: ModerationSubjectPost,
opts: ModerationOpts,
): ModerationDecision {
const acc = new ModerationCauseAccumulator()
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(label, opts)
acc.addLabel('content', label, opts)
}
}
return acc.finalizeDecision(opts)
return acc
}

@ -1,24 +1,19 @@
import { ModerationCauseAccumulator } from '../accumulator'
import {
Label,
ModerationSubjectProfile,
ModerationOpts,
ModerationDecision,
} from '../types'
import { ModerationDecision } from '../decision'
import { Label, ModerationSubjectProfile, ModerationOpts } from '../types'
export function decideProfile(
subject: ModerationSubjectProfile,
opts: ModerationOpts,
): ModerationDecision {
const acc = new ModerationCauseAccumulator()
const acc = new ModerationDecision()
acc.setDid(subject.did)
acc.setIsMe(subject.did === opts.userDid)
for (const label of filterProfileLabels(subject.labels)) {
acc.addLabel(label, opts)
acc.addLabel('profile', label, opts)
}
return acc.finalizeDecision(opts)
return acc
}
export function filterProfileLabels(labels?: Label[]): Label[] {

@ -1,80 +0,0 @@
import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia } from '../../client'
import { ModerationCauseAccumulator } from '../accumulator'
import { ModerationOpts, ModerationDecision } from '../types'
import { decideAccount } from './account'
export function decideQuotedPost(
subject: AppBskyEmbedRecord.View,
opts: ModerationOpts,
): ModerationDecision {
const acc = new ModerationCauseAccumulator()
if (AppBskyEmbedRecord.isViewRecord(subject.record)) {
acc.setDid(subject.record.author.did)
if (subject.record.labels?.length) {
for (const label of subject.record.labels) {
acc.addLabel(label, opts)
}
}
} else if (AppBskyEmbedRecord.isViewBlocked(subject.record)) {
acc.setDid(subject.record.author.did)
if (subject.record.author.viewer?.blocking) {
acc.addBlocking(subject.record.author.viewer?.blocking)
} else if (subject.record.author.viewer?.blockedBy) {
acc.addBlockedBy(subject.record.author.viewer?.blockedBy)
} else {
acc.addBlockOther(true)
}
}
return acc.finalizeDecision(opts)
}
export function decideQuotedPostAccount(
subject: AppBskyEmbedRecord.View,
opts: ModerationOpts,
): ModerationDecision {
if (AppBskyEmbedRecord.isViewRecord(subject.record)) {
return decideAccount(subject.record.author, opts)
}
return ModerationDecision.noop()
}
export function decideQuotedPostWithMedia(
subject: AppBskyEmbedRecordWithMedia.View,
opts: ModerationOpts,
): ModerationDecision {
const acc = new ModerationCauseAccumulator()
if (AppBskyEmbedRecord.isViewRecord(subject.record.record)) {
acc.setDid(subject.record.record.author.did)
if (subject.record.record.labels?.length) {
for (const label of subject.record.record.labels) {
acc.addLabel(label, opts)
}
}
} else if (AppBskyEmbedRecord.isViewBlocked(subject.record.record)) {
acc.setDid(subject.record.record.author.did)
if (subject.record.record.author.viewer?.blocking) {
acc.addBlocking(subject.record.record.author.viewer?.blocking)
} else if (subject.record.record.author.viewer?.blockedBy) {
acc.addBlockedBy(subject.record.record.author.viewer?.blockedBy)
} else {
acc.addBlockOther(true)
}
}
return acc.finalizeDecision(opts)
}
export function decideQuotedPostWithMediaAccount(
subject: AppBskyEmbedRecordWithMedia.View,
opts: ModerationOpts,
): ModerationDecision {
if (AppBskyEmbedRecord.isViewRecord(subject.record.record)) {
return decideAccount(subject.record.record.author, opts)
}
return ModerationDecision.noop()
}

@ -1,13 +1,10 @@
import {
ModerationSubjectUserList,
ModerationOpts,
ModerationDecision,
} from '../types'
import { ModerationDecision } from '../decision'
import { ModerationSubjectUserList, ModerationOpts } from '../types'
export function decideUserList(
_subject: ModerationSubjectUserList,
_opts: ModerationOpts,
): ModerationDecision {
// TODO handle labels applied on the list itself
return ModerationDecision.noop()
return new ModerationDecision()
}

@ -1,72 +1,81 @@
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyNotificationListNotifications,
AppBskyGraphDefs,
ComAtprotoLabelDefs,
} from '../client/index'
import { KnownLabelValue } from './const/labels'
// syntax
// =
export const CUSTOM_LABEL_VALUE_RE = /^[a-z-]+$/
// behaviors
// =
export interface ModerationBehavior {
profileList?: 'blur' | 'alert' | 'inform'
profileView?: 'blur' | 'alert' | 'inform'
avatar?: 'blur' | 'alert'
banner?: 'blur'
displayName?: 'blur'
contentList?: 'blur' | 'alert' | 'inform'
contentView?: 'blur' | 'alert' | 'inform'
contentMedia?: 'blur'
}
export const BLOCK_BEHAVIOR: ModerationBehavior = {
profileList: 'blur',
profileView: 'alert',
avatar: 'blur',
banner: 'blur',
contentList: 'blur',
contentView: 'blur',
}
export const MUTE_BEHAVIOR: ModerationBehavior = {
profileList: 'inform',
profileView: 'alert',
contentList: 'blur',
contentView: 'inform',
}
export const HIDE_BEHAVIOR: ModerationBehavior = {
contentList: 'blur',
contentView: 'blur',
}
export const NOOP_BEHAVIOR: ModerationBehavior = {}
// labels
// =
export type Label = ComAtprotoLabelDefs.Label
export type LabelTarget = 'account' | 'profile' | 'content'
export type LabelPreference = 'ignore' | 'warn' | 'hide'
export type LabelDefinitionFlag = 'no-override' | 'adult' | 'unauthed'
export type LabelDefinitionOnWarnBehavior =
| 'blur'
| 'blur-media'
| 'alert'
| null
export interface LabelDefinitionLocalizedStrings {
name: string
description: string
export type LabelValueDefinitionFlag =
| 'no-override'
| 'adult'
| 'unauthed'
| 'no-self'
export interface InterprettedLabelValueDefinition
extends ComAtprotoLabelDefs.LabelValueDefinition {
// identifier: string
configurable: boolean
defaultSetting: LabelPreference // type narrowing
flags: LabelValueDefinitionFlag[]
behaviors: {
account?: ModerationBehavior
profile?: ModerationBehavior
content?: ModerationBehavior
}
}
export type LabelDefinitionLocalizedStringsMap = Record<
string,
LabelDefinitionLocalizedStrings
export type LabelDefinitionMap = Record<
KnownLabelValue,
InterprettedLabelValueDefinition
>
export interface LabelDefinition {
id: string
groupId: string
configurable: boolean
preferences: LabelPreference[]
flags: LabelDefinitionFlag[]
onwarn: LabelDefinitionOnWarnBehavior
strings: {
settings: LabelDefinitionLocalizedStringsMap
account: LabelDefinitionLocalizedStringsMap
content: LabelDefinitionLocalizedStringsMap
}
}
export interface LabelGroupDefinition {
id: string
configurable: boolean
labels: LabelDefinition[]
strings: {
settings: LabelDefinitionLocalizedStringsMap
}
}
export type LabelDefinitionMap = Record<string, LabelDefinition>
export type LabelGroupDefinitionMap = Record<string, LabelGroupDefinition>
// labelers
// =
interface Labeler {
did: string
displayName: string
}
export interface LabelerSettings {
labeler: Labeler
labels: Record<string, LabelPreference>
}
// subjects
// =
@ -77,6 +86,9 @@ export type ModerationSubjectProfile =
export type ModerationSubjectPost = AppBskyFeedDefs.PostView
export type ModerationSubjectNotification =
AppBskyNotificationListNotifications.Notification
export type ModerationSubjectFeedGenerator = AppBskyFeedDefs.GeneratorView
export type ModerationSubjectUserList =
@ -86,6 +98,7 @@ export type ModerationSubjectUserList =
export type ModerationSubject =
| ModerationSubjectProfile
| ModerationSubjectPost
| ModerationSubjectNotification
| ModerationSubjectFeedGenerator
| ModerationSubjectUserList
@ -95,7 +108,7 @@ export type ModerationSubject =
export type ModerationCauseSource =
| { type: 'user' }
| { type: 'list'; list: AppBskyGraphDefs.ListViewBasic }
| { type: 'labeler'; labeler: Labeler }
| { type: 'labeler'; did: string }
export type ModerationCause =
| { type: 'blocking'; source: ModerationCauseSource; priority: 3 }
@ -105,40 +118,31 @@ export type ModerationCause =
type: 'label'
source: ModerationCauseSource
label: Label
labelDef: LabelDefinition
labelDef: InterprettedLabelValueDefinition
setting: LabelPreference
behavior: ModerationBehavior
noOverride: boolean
priority: 1 | 2 | 5 | 7 | 8
}
| { type: 'muted'; source: ModerationCauseSource; priority: 6 }
| { type: 'hidden'; source: ModerationCauseSource; priority: 6 }
export interface ModerationOpts {
userDid: string
export interface ModerationPrefsModerator {
did: string
labels: Record<string, LabelPreference>
}
export interface ModerationPrefs {
adultContentEnabled: boolean
labels: Record<string, LabelPreference>
labelers: LabelerSettings[]
mods: ModerationPrefsModerator[]
}
export class ModerationDecision {
static noop() {
return new ModerationDecision()
}
constructor(
public cause: ModerationCause | undefined = undefined,
public alert: boolean = false,
public blur: boolean = false,
public blurMedia: boolean = false,
public filter: boolean = false,
public noOverride: boolean = false,
public additionalCauses: ModerationCause[] = [],
public did: string = '',
) {}
}
export interface ModerationUI {
filter?: boolean
blur?: boolean
alert?: boolean
cause?: ModerationCause
noOverride?: boolean
export interface ModerationOpts {
userDid: string | undefined
prefs: ModerationPrefs
/**
* Map of labeler did -> custom definitions
*/
labelDefs?: Record<string, InterprettedLabelValueDefinition[]>
}

@ -0,0 +1,21 @@
import { ModerationCause } from './types'
export class ModerationUI {
noOverride: boolean = false
filters: ModerationCause[] = []
blurs: ModerationCause[] = []
alerts: ModerationCause[] = []
informs: ModerationCause[] = []
get filter(): boolean {
return this.filters.length !== 0
}
get blur(): boolean {
return this.blurs.length !== 0
}
get alert(): boolean {
return this.alerts.length !== 0
}
get inform(): boolean {
return this.informs.length !== 0
}
}

@ -1,69 +1,10 @@
import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia } from '../client'
import { ModerationDecision, ModerationUI } from './types'
export function takeHighestPriorityDecision(
...decisions: (ModerationDecision | undefined)[]
): ModerationDecision {
// remove undefined decisions
const filtered = decisions.filter((d) => !!d) as ModerationDecision[]
if (filtered.length === 0) {
return ModerationDecision.noop()
}
// sort by highest priority
filtered.sort((a, b) => {
if (a.cause && b.cause) {
return a.cause.priority - b.cause.priority
}
if (a.cause) {
return -1
}
if (b.cause) {
return 1
}
return 0
})
// use the top priority
return filtered[0]
}
export function downgradeDecision(
decision: ModerationDecision,
to: 'blur' | 'alert' | 'noop',
) {
decision.filter = false
decision.noOverride = false
if (to === 'noop') {
decision.blur = false
decision.blurMedia = false
decision.alert = false
delete decision.cause
} else if (to === 'alert') {
decision.blur = false
decision.blurMedia = false
decision.alert = true
}
}
export function isModerationDecisionNoop(
decision: ModerationDecision | undefined,
{ ignoreFilter }: { ignoreFilter: boolean } = { ignoreFilter: false },
): boolean {
if (!decision) {
return true
}
if (decision.alert) {
return false
}
if (decision.blur) {
return false
}
if (decision.filter && !ignoreFilter) {
return false
}
return true
}
import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyLabelerDefs,
ComAtprotoLabelDefs,
} from '../client'
import { InterprettedLabelValueDefinition, ModerationBehavior } from './types'
export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View {
return Boolean(embed && AppBskyEmbedRecord.isView(embed))
@ -75,12 +16,81 @@ export function isQuotedPostWithMedia(
return Boolean(embed && AppBskyEmbedRecordWithMedia.isView(embed))
}
export function toModerationUI(decision: ModerationDecision): ModerationUI {
export function interpretLabelValueDefinition(
def: ComAtprotoLabelDefs.LabelValueDefinition,
): InterprettedLabelValueDefinition {
const behaviors: {
account: ModerationBehavior
profile: ModerationBehavior
content: ModerationBehavior
} = {
account: {},
profile: {},
content: {},
}
const alertOrInform: 'alert' | 'inform' | undefined =
def.severity === 'alert'
? 'alert'
: def.severity === 'inform'
? 'inform'
: undefined
if (def.blurs === 'content') {
// target=account, blurs=content
behaviors.account.profileList = alertOrInform
behaviors.account.profileView = alertOrInform
behaviors.account.contentList = 'blur'
behaviors.account.contentView = alertOrInform
// target=profile, blurs=content
behaviors.account.profileView = alertOrInform
behaviors.profile.avatar = 'blur'
behaviors.profile.banner = 'blur'
behaviors.profile.displayName = 'blur'
// target=content, blurs=content
behaviors.content.contentList = 'blur'
behaviors.content.contentView = alertOrInform
} else if (def.blurs === 'media') {
// target=account, blurs=media
behaviors.account.profileList = alertOrInform
behaviors.account.profileView = alertOrInform
behaviors.account.avatar = 'blur'
behaviors.account.banner = 'blur'
behaviors.account.contentMedia = 'blur'
// target=profile, blurs=media
behaviors.profile.profileView = alertOrInform
behaviors.profile.avatar = 'blur'
behaviors.profile.banner = 'blur'
// target=content, blurs=media
behaviors.content.contentMedia = 'blur'
} else if (def.blurs === 'none') {
// target=account, blurs=none
behaviors.account.profileList = alertOrInform
behaviors.account.profileView = alertOrInform
behaviors.account.contentList = alertOrInform
behaviors.account.contentView = alertOrInform
// target=profile, blurs=none
behaviors.profile.profileView = alertOrInform
// target=content, blurs=none
behaviors.content.contentList = alertOrInform
behaviors.content.contentView = alertOrInform
}
return {
cause: decision.cause,
filter: decision.filter,
blur: decision.blur,
alert: decision.alert,
noOverride: decision.noOverride,
...def,
configurable: true,
defaultSetting: 'warn',
flags: ['no-self'],
behaviors,
}
}
export function interpretLabelValueDefinitions(
modserviceView: AppBskyLabelerDefs.LabelerViewDetailed,
): InterprettedLabelValueDefinition[] {
return (modserviceView.policies?.labelValueDefinitions || [])
.filter(
(labelValDef) =>
ComAtprotoLabelDefs.isLabelValueDefinition(labelValDef) &&
ComAtprotoLabelDefs.validateLabelValueDefinition(labelValDef).success,
)
.map((labelValDef) => interpretLabelValueDefinition(labelValDef))
}

@ -1,5 +1,5 @@
import { AppBskyActorNS, AppBskyActorDefs } from './client'
import { LabelPreference } from './moderation/types'
import { AppBskyActorDefs } from './client'
import { ModerationPrefs } from './moderation/types'
/**
* Used by the PersistSessionHandler to indicate what change occurred
@ -70,12 +70,6 @@ export interface AtpAgentGlobalOpts {
fetch: AtpAgentFetchHandler
}
/**
* Content-label preference
*/
export type BskyLabelPreference = LabelPreference | 'show'
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
/**
* Bluesky feed view preferences
*/
@ -116,8 +110,7 @@ export interface BskyPreferences {
}
feedViewPrefs: Record<string, BskyFeedViewPreference>
threadViewPrefs: BskyThreadViewPreference
adultContentEnabled: boolean
contentLabels: Record<string, BskyLabelPreference>
moderationPrefs: ModerationPrefs
birthDate: Date | undefined
interests: BskyInterestsPreference
mutedWords: AppBskyActorDefs.MutedWord[]

@ -8,6 +8,7 @@ import {
} from '..'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { getPdsEndpoint, isValidDidDoc } from '@atproto/common-web'
import { createHeaderEchoServer } from './util/echo-server'
describe('agent', () => {
let network: TestNetworkNoAppView
@ -479,6 +480,25 @@ describe('agent', () => {
expect(sessions[0]).toEqual(undefined)
})
})
describe('configureLabelersHeader', () => {
it('adds the labelers header as expected', async () => {
const server = await createHeaderEchoServer(15991)
const agent = new AtpAgent({ service: 'http://localhost:15991' })
agent.configureLabelersHeader(['did:plc:test1'])
const res1 = await agent.com.atproto.server.describeServer()
expect(res1.data['atproto-labelers']).toEqual('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',
)
await new Promise((r) => server.close(r))
})
})
})
const createPost = async (agent: AtpAgent) => {

@ -1,5 +1,11 @@
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { BskyAgent, ComAtprotoRepoPutRecord, AppBskyActorProfile } from '..'
import {
BskyAgent,
ComAtprotoRepoPutRecord,
AppBskyActorProfile,
BSKY_MODSERVICE_DID,
DEFAULT_LABEL_SETTINGS,
} from '..'
describe('agent', () => {
let network: TestNetworkNoAppView
@ -220,8 +226,16 @@ describe('agent', () => {
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
adultContentEnabled: false,
contentLabels: {},
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
@ -246,8 +260,16 @@ describe('agent', () => {
await agent.setAdultContentEnabled(true)
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
adultContentEnabled: true,
contentLabels: {},
moderationPrefs: {
adultContentEnabled: true,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
@ -272,35 +294,15 @@ describe('agent', () => {
await agent.setAdultContentEnabled(false)
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
adultContentEnabled: false,
contentLabels: {},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
interests: {
tags: [],
},
mutedWords: [],
hiddenPosts: [],
})
await agent.setContentLabelPref('impersonation', 'warn')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
adultContentEnabled: false,
contentLabels: {
impersonation: 'warn',
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -323,14 +325,56 @@ describe('agent', () => {
hiddenPosts: [],
})
await agent.setContentLabelPref('spam', 'show') // will convert to 'ignore'
await agent.setContentLabelPref('impersonation', 'hide')
await agent.setContentLabelPref('misinfo', 'hide')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' },
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
interests: {
tags: [],
},
mutedWords: [],
hiddenPosts: [],
})
await agent.setContentLabelPref('spam', 'ignore')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -359,10 +403,19 @@ describe('agent', () => {
pinned: [],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -391,10 +444,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake'],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -423,10 +485,19 @@ describe('agent', () => {
pinned: [],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -455,10 +526,19 @@ describe('agent', () => {
pinned: [],
saved: [],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -487,10 +567,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake'],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -525,10 +614,19 @@ describe('agent', () => {
'at://bob.com/app.bsky.feed.generator/fake2',
],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -557,10 +655,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
@ -589,10 +696,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -621,10 +737,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -653,10 +778,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -685,10 +819,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -724,10 +867,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -763,10 +915,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -802,10 +963,19 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake2'],
saved: ['at://bob.com/app.bsky.feed.generator/fake2'],
},
adultContentEnabled: false,
contentLabels: {
impersonation: 'hide',
spam: 'ignore',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
misinfo: 'hide',
spam: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -849,24 +1019,43 @@ describe('agent', () => {
preferences: [
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
label: 'porn',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
label: 'porn',
visibility: 'hide',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
label: 'porn',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
label: 'porn',
visibility: 'warn',
},
{
$type: 'app.bsky.actor.defs#modsPref',
mods: [
{
did: BSKY_MODSERVICE_DID,
},
],
},
{
$type: 'app.bsky.actor.defs#modsPref',
mods: [
{
did: BSKY_MODSERVICE_DID,
},
{
did: 'did:plc:other',
},
],
},
{
$type: 'app.bsky.actor.defs#adultContentPref',
enabled: true,
@ -938,9 +1127,22 @@ describe('agent', () => {
pinned: [],
saved: [],
},
adultContentEnabled: true,
contentLabels: {
nsfw: 'warn',
moderationPrefs: {
adultContentEnabled: true,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'warn',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
{
did: 'did:plc:other',
labels: {},
},
],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -969,9 +1171,22 @@ describe('agent', () => {
pinned: [],
saved: [],
},
adultContentEnabled: false,
contentLabels: {
nsfw: 'warn',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'warn',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
{
did: 'did:plc:other',
labels: {},
},
],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -994,15 +1209,68 @@ describe('agent', () => {
hiddenPosts: [],
})
await agent.setContentLabelPref('nsfw', 'hide')
await agent.setContentLabelPref('porn', 'ignore')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: {
pinned: [],
saved: [],
},
adultContentEnabled: false,
contentLabels: {
nsfw: 'hide',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
{
did: 'did:plc:other',
labels: {},
},
],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
home: {
hideReplies: true,
hideRepliesByUnfollowed: false,
hideRepliesByLikeCount: 10,
hideReposts: true,
hideQuotePosts: true,
},
},
threadViewPrefs: {
sort: 'newest',
prioritizeFollowedUsers: false,
},
interests: {
tags: [],
},
mutedWords: [],
hiddenPosts: [],
})
await agent.removeModService('did:plc:other')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: {
pinned: [],
saved: [],
},
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -1031,9 +1299,18 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake'],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
nsfw: 'hide',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2021-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -1062,9 +1339,18 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake'],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
nsfw: 'hide',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -1104,9 +1390,18 @@ describe('agent', () => {
pinned: ['at://bob.com/app.bsky.feed.generator/fake'],
saved: ['at://bob.com/app.bsky.feed.generator/fake'],
},
adultContentEnabled: false,
contentLabels: {
nsfw: 'hide',
moderationPrefs: {
adultContentEnabled: false,
labels: {
...DEFAULT_LABEL_SETTINGS,
porn: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: new Date('2023-09-11T18:05:42.556Z'),
feedViewPrefs: {
@ -1138,8 +1433,16 @@ describe('agent', () => {
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
visibility: 'hide',
label: 'porn',
visibility: 'ignore',
},
{
$type: 'app.bsky.actor.defs#modsPref',
mods: [
{
did: BSKY_MODSERVICE_DID,
},
],
},
{
$type: 'app.bsky.actor.defs#savedFeedsPref',

@ -0,0 +1,969 @@
import { moderateProfile, moderatePost } from '../src'
import {
ModerationBehaviorSuiteRunner,
SuiteUsers,
SuiteConfigurations,
SuiteScenarios,
ModerationTestSuiteScenario,
} from './util/moderation-behavior'
const USERS: SuiteUsers = {
self: {
blocking: false,
blockingByList: false,
blockedBy: false,
muted: false,
mutedByList: false,
},
alice: {
blocking: false,
blockingByList: false,
blockedBy: false,
muted: false,
mutedByList: false,
},
bob: {
blocking: true,
blockingByList: false,
blockedBy: false,
muted: false,
mutedByList: false,
},
carla: {
blocking: false,
blockingByList: false,
blockedBy: true,
muted: false,
mutedByList: false,
},
dan: {
blocking: false,
blockingByList: false,
blockedBy: false,
muted: true,
mutedByList: false,
},
elise: {
blocking: false,
blockingByList: false,
blockedBy: false,
muted: false,
mutedByList: true,
},
fern: {
blocking: true,
blockingByList: false,
blockedBy: true,
muted: false,
mutedByList: false,
},
georgia: {
blocking: false,
blockingByList: true,
blockedBy: false,
muted: false,
mutedByList: false,
},
}
const CONFIGURATIONS: SuiteConfigurations = {
none: {},
'adult-disabled': {
adultContentEnabled: false,
},
'intolerant-hide': {
settings: { intolerance: 'hide' },
},
'intolerant-warn': {
settings: { intolerance: 'warn' },
},
'intolerant-ignore': {
settings: { intolerance: 'ignore' },
},
'porn-hide': {
adultContentEnabled: true,
settings: { porn: 'hide' },
},
'porn-warn': {
adultContentEnabled: true,
settings: { porn: 'warn' },
},
'porn-ignore': {
adultContentEnabled: true,
settings: { porn: 'ignore' },
},
'scam-hide': {
settings: { misrepresentation: 'hide' },
},
'scam-warn': {
settings: { misrepresentation: 'warn' },
},
'scam-ignore': {
settings: { misrepresentation: 'ignore' },
},
'intolerant-hide-scam-warn': {
settings: { intolerance: 'hide', misrepresentation: 'hide' },
},
'logged-out': {
authed: false,
},
}
const SCENARIOS: SuiteScenarios = {
"Imperative label ('!hide') on account": {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { account: ['!hide'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!hide') on profile": {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { profile: ['!hide'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
"Imperative label ('!hide') on post": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { post: ['!hide'] },
behaviors: {
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!hide') on author profile": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { profile: ['!hide'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
"Imperative label ('!hide') on author account": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { account: ['!hide'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"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',
author: 'alice',
labels: { account: ['!warn'] },
behaviors: {
profileList: ['blur'],
profileView: ['blur'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['blur'],
contentView: ['blur'],
},
},
"Imperative label ('!warn') on profile": {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { profile: ['!warn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
displayName: ['blur'],
},
},
"Imperative label ('!warn') on post": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { post: ['!warn'] },
behaviors: {
contentList: ['blur'],
contentView: ['blur'],
},
},
"Imperative label ('!warn') on author profile": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { profile: ['!warn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
displayName: ['blur'],
},
},
"Imperative label ('!warn') on author account": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { account: ['!warn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
contentList: ['blur'],
contentView: ['blur'],
},
},
"Imperative label ('!no-unauthenticated') on account when logged out": {
cfg: 'logged-out',
subject: 'profile',
author: 'alice',
labels: { account: ['!no-unauthenticated'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!no-unauthenticated') on profile when logged out": {
cfg: 'logged-out',
subject: 'profile',
author: 'alice',
labels: { profile: ['!no-unauthenticated'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!no-unauthenticated') on post when logged out": {
cfg: 'logged-out',
subject: 'post',
author: 'alice',
labels: { post: ['!no-unauthenticated'] },
behaviors: {
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!no-unauthenticated') on author profile when logged out":
{
cfg: 'logged-out',
subject: 'post',
author: 'alice',
labels: { profile: ['!no-unauthenticated'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!no-unauthenticated') on author account when logged out":
{
cfg: 'logged-out',
subject: 'post',
author: 'alice',
labels: { account: ['!no-unauthenticated'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Imperative label ('!no-unauthenticated') on account when logged in": {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { account: ['!no-unauthenticated'] },
behaviors: {},
},
"Imperative label ('!no-unauthenticated') on profile when logged in": {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { profile: ['!no-unauthenticated'] },
behaviors: {},
},
"Imperative label ('!no-unauthenticated') on post when logged in": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { post: ['!no-unauthenticated'] },
behaviors: {},
},
"Imperative label ('!no-unauthenticated') on author profile when logged in": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { profile: ['!no-unauthenticated'] },
behaviors: {},
},
"Imperative label ('!no-unauthenticated') on author account when logged in": {
cfg: 'none',
subject: 'post',
author: 'alice',
labels: { account: ['!no-unauthenticated'] },
behaviors: {},
},
"Blur-media label ('porn') on account (hide)": {
cfg: 'porn-hide',
subject: 'profile',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
},
"Blur-media label ('porn') on profile (hide)": {
cfg: 'porn-hide',
subject: 'profile',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
},
"Blur-media label ('porn') on post (hide)": {
cfg: 'porn-hide',
subject: 'post',
author: 'alice',
labels: { post: ['porn'] },
behaviors: {
contentList: ['filter'],
contentMedia: ['blur'],
},
},
"Blur-media label ('porn') on author profile (hide)": {
cfg: 'porn-hide',
subject: 'post',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
},
"Blur-media label ('porn') on author account (hide)": {
cfg: 'porn-hide',
subject: 'post',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
},
"Blur-media label ('porn') on account (warn)": {
cfg: 'porn-warn',
subject: 'profile',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
},
},
"Blur-media label ('porn') on profile (warn)": {
cfg: 'porn-warn',
subject: 'profile',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
},
},
"Blur-media label ('porn') on post (warn)": {
cfg: 'porn-warn',
subject: 'post',
author: 'alice',
labels: { post: ['porn'] },
behaviors: {
contentMedia: ['blur'],
},
},
"Blur-media label ('porn') on author profile (warn)": {
cfg: 'porn-warn',
subject: 'post',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
},
},
"Blur-media label ('porn') on author account (warn)": {
cfg: 'porn-warn',
subject: 'post',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
avatar: ['blur'],
banner: ['blur'],
},
},
"Blur-media label ('porn') on account (ignore)": {
cfg: 'porn-ignore',
subject: 'profile',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {},
},
"Blur-media label ('porn') on profile (ignore)": {
cfg: 'porn-ignore',
subject: 'profile',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {},
},
"Blur-media label ('porn') on post (ignore)": {
cfg: 'porn-ignore',
subject: 'post',
author: 'alice',
labels: { post: ['porn'] },
behaviors: {},
},
"Blur-media label ('porn') on author profile (ignore)": {
cfg: 'porn-ignore',
subject: 'post',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {},
},
"Blur-media label ('porn') on author account (ignore)": {
cfg: 'porn-ignore',
subject: 'post',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {},
},
'Adult-only label on account when adult content is disabled': {
cfg: 'adult-disabled',
subject: 'profile',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
'Adult-only label on profile when adult content is disabled': {
cfg: 'adult-disabled',
subject: 'profile',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
profileList: ['filter'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
'Adult-only label on post when adult content is disabled': {
cfg: 'adult-disabled',
subject: 'post',
author: 'alice',
labels: { post: ['porn'] },
behaviors: {
contentList: ['filter'],
contentMedia: ['blur', 'noOverride'],
},
},
'Adult-only label on author profile when adult content is disabled': {
cfg: 'adult-disabled',
subject: 'post',
author: 'alice',
labels: { profile: ['porn'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
'Adult-only label on author account when adult content is disabled': {
cfg: 'adult-disabled',
subject: 'post',
author: 'alice',
labels: { account: ['porn'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter'],
},
},
'Self-profile: !hide on account': {
cfg: 'none',
subject: 'profile',
author: 'self',
labels: { account: ['!hide'] },
behaviors: {},
},
'Self-profile: !hide on profile': {
cfg: 'none',
subject: 'profile',
author: 'self',
labels: { profile: ['!hide'] },
behaviors: {},
},
"Self-post: Imperative label ('!hide') on post": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { post: ['!hide'] },
behaviors: {},
},
"Self-post: Imperative label ('!hide') on author profile": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { profile: ['!hide'] },
behaviors: {},
},
"Self-post: Imperative label ('!hide') on author account": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { account: ['!hide'] },
behaviors: {},
},
"Self-post: Imperative label ('!warn') on post": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { post: ['!warn'] },
behaviors: {},
},
"Self-post: Imperative label ('!warn') on author profile": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { profile: ['!warn'] },
behaviors: {},
},
"Self-post: Imperative label ('!warn') on author account": {
cfg: 'none',
subject: 'post',
author: 'self',
labels: { account: ['!warn'] },
behaviors: {},
},
'Mute/block: Blocking user': {
cfg: 'none',
subject: 'profile',
author: 'bob',
labels: {},
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['alert'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Post with blocked author': {
cfg: 'none',
subject: 'post',
author: 'bob',
labels: {},
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Post with author blocking user': {
cfg: 'none',
subject: 'post',
author: 'carla',
labels: {},
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Mute/block: Blocking-by-list user': {
cfg: 'none',
subject: 'profile',
author: 'georgia',
labels: {},
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['alert'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Mute/block: Blocked by user': {
cfg: 'none',
subject: 'profile',
author: 'carla',
labels: {},
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['alert'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Mute/block: Muted user': {
cfg: 'none',
subject: 'profile',
author: 'dan',
labels: {},
behaviors: {
profileList: ['filter', 'inform'],
profileView: ['alert'],
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
},
'Mute/block: Muted-by-list user': {
cfg: 'none',
subject: 'profile',
author: 'elise',
labels: {},
behaviors: {
profileList: ['filter', 'inform'],
profileView: ['alert'],
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
},
'Merging: blocking & blocked-by user': {
cfg: 'none',
subject: 'profile',
author: 'fern',
labels: {},
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['alert'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Post with muted author': {
cfg: 'none',
subject: 'post',
author: 'dan',
labels: {},
behaviors: {
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
},
'Post with muted-by-list author': {
cfg: 'none',
subject: 'post',
author: 'elise',
labels: {},
behaviors: {
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
},
"Merging: '!hide' label on account of blocked user": {
cfg: 'none',
subject: 'profile',
author: 'bob',
labels: { account: ['!hide'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'alert', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Merging: '!hide' and 'porn' labels on account (hide)": {
cfg: 'porn-hide',
subject: 'profile',
author: 'alice',
labels: { account: ['!hide', 'porn'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Merging: '!warn' and 'porn' labels on account (hide)": {
cfg: 'porn-hide',
subject: 'profile',
author: 'alice',
labels: { account: ['!warn', 'porn'] },
behaviors: {
profileList: ['filter', 'blur'],
profileView: ['blur'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter', 'blur'],
contentView: ['blur'],
},
},
'Merging: !hide on account, !warn on profile': {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { account: ['!hide'], profile: ['!warn'] },
behaviors: {
profileList: ['filter', 'blur', 'noOverride'],
profileView: ['blur', 'noOverride'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
'Merging: !warn on account, !hide on profile': {
cfg: 'none',
subject: 'profile',
author: 'alice',
labels: { account: ['!warn'], profile: ['!hide'] },
behaviors: {
profileList: ['filter', 'blur'],
profileView: ['blur'],
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
displayName: ['blur', 'noOverride'],
contentList: ['filter', 'blur'],
contentView: ['blur'],
},
},
'Merging: post with blocking & blocked-by author': {
cfg: 'none',
subject: 'post',
author: 'fern',
labels: {},
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Merging: '!hide' label on post by blocked user": {
cfg: 'none',
subject: 'post',
author: 'bob',
labels: { post: ['!hide'] },
behaviors: {
avatar: ['blur', 'noOverride'],
banner: ['blur', 'noOverride'],
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
},
},
"Merging: '!hide' and 'porn' labels on post (hide)": {
cfg: 'porn-hide',
subject: 'post',
author: 'alice',
labels: { post: ['!hide', 'porn'] },
behaviors: {
contentList: ['filter', 'blur', 'noOverride'],
contentView: ['blur', 'noOverride'],
contentMedia: ['blur'],
},
},
"Merging: '!warn' and 'porn' labels on post (hide)": {
cfg: 'porn-hide',
subject: 'post',
author: 'alice',
labels: { post: ['!warn', 'porn'] },
behaviors: {
contentList: ['filter', 'blur'],
contentView: ['blur'],
contentMedia: ['blur'],
},
},
}
const suite = new ModerationBehaviorSuiteRunner(
USERS,
CONFIGURATIONS,
SCENARIOS,
)
describe('Post moderation behaviors', () => {
const scenarios = Array.from(Object.entries(suite.scenarios)).filter(
([name]) => !name.startsWith('//'),
)
it.each(scenarios)(
'%s',
(_name: string, scenario: ModerationTestSuiteScenario) => {
const res =
scenario.subject === 'profile'
? moderateProfile(
suite.profileScenario(scenario),
suite.moderationOpts(scenario),
)
: moderatePost(
suite.postScenario(scenario),
suite.moderationOpts(scenario),
)
if (scenario.subject === 'profile') {
expect(res.ui('profileList')).toBeModerationResult(
scenario.behaviors.profileList,
'profileList',
JSON.stringify(res, null, 2),
)
expect(res.ui('profileView')).toBeModerationResult(
scenario.behaviors.profileView,
'profileView',
JSON.stringify(res, null, 2),
)
}
expect(res.ui('avatar')).toBeModerationResult(
scenario.behaviors.avatar,
'avatar',
JSON.stringify(res, null, 2),
)
expect(res.ui('banner')).toBeModerationResult(
scenario.behaviors.banner,
'banner',
JSON.stringify(res, null, 2),
)
expect(res.ui('displayName')).toBeModerationResult(
scenario.behaviors.displayName,
'displayName',
JSON.stringify(res, null, 2),
)
expect(res.ui('contentList')).toBeModerationResult(
scenario.behaviors.contentList,
'contentList',
JSON.stringify(res, null, 2),
)
expect(res.ui('contentView')).toBeModerationResult(
scenario.behaviors.contentView,
'contentView',
JSON.stringify(res, null, 2),
)
expect(res.ui('contentMedia')).toBeModerationResult(
scenario.behaviors.contentMedia,
'contentMedia',
JSON.stringify(res, null, 2),
)
},
)
})

@ -0,0 +1,358 @@
import {
moderateProfile,
moderatePost,
mock,
ModerationOpts,
InterprettedLabelValueDefinition,
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', 'alert'],
profileView: ['alert'],
contentList: ['filter', 'blur'],
contentView: ['alert'],
},
profile: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
displayName: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: ['alert'],
},
},
{
blurs: 'content',
severity: 'inform',
account: {
profileList: ['filter', 'inform'],
profileView: ['inform'],
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
profile: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
displayName: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: ['inform'],
},
},
{
blurs: 'content',
severity: 'none',
account: {
profileList: ['filter'],
profileView: [],
contentList: ['filter', 'blur'],
contentView: [],
},
profile: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
displayName: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter', 'blur'],
contentView: [],
},
},
{
blurs: 'media',
severity: 'alert',
account: {
profileList: ['filter', 'alert'],
profileView: ['alert'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
contentMedia: ['blur'],
},
profile: {
profileList: ['filter'],
profileView: ['alert'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
},
{
blurs: 'media',
severity: 'inform',
account: {
profileList: ['filter', 'inform'],
profileView: ['inform'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
contentMedia: ['blur'],
},
profile: {
profileList: ['filter'],
profileView: ['inform'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
},
{
blurs: 'media',
severity: 'none',
account: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
contentMedia: ['blur'],
},
profile: {
profileList: ['filter'],
avatar: ['blur'],
banner: ['blur'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter'],
contentMedia: ['blur'],
},
},
{
blurs: 'none',
severity: 'alert',
account: {
profileList: ['filter', 'alert'],
profileView: ['alert'],
contentList: ['filter', 'alert'],
contentView: ['alert'],
},
profile: {
profileList: ['filter'],
profileView: ['alert'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter', 'alert'],
contentView: ['alert'],
},
},
{
blurs: 'none',
severity: 'inform',
account: {
profileList: ['filter', 'inform'],
profileView: ['inform'],
contentList: ['filter', 'inform'],
contentView: ['inform'],
},
profile: {
profileList: ['filter'],
profileView: ['inform'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
contentList: ['filter', 'inform'],
contentView: ['inform'],
},
},
{
blurs: 'none',
severity: 'none',
account: {
profileList: ['filter'],
contentList: ['filter'],
},
profile: {
profileList: ['filter'],
contentList: ['filter'],
},
post: {
profileList: ['filter'],
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 res
if (target === 'post') {
res = moderatePost(
mock.postView({
record: {
text: 'Hello',
createdAt: new Date().toISOString(),
},
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
}),
labels: [
mock.label({
val: 'custom',
uri: 'at://did:web:bob.test/app.bsky.feed.post/fake',
src: 'did:web:labeler.test',
}),
],
}),
modOpts(blurs, severity),
)
} else if (target === 'profile') {
res = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
mock.label({
val: 'custom',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
src: 'did:web:labeler.test',
}),
],
}),
modOpts(blurs, severity),
)
} else {
res = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
mock.label({
val: 'custom',
uri: 'did:web:bob.test',
src: 'did:web:labeler.test',
}),
],
}),
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: {},
mods: [
{
did: 'did:web:labeler.test',
labels: { custom: 'hide' },
},
],
},
labelDefs: {
'did:web:labeler.test': [makeCustomLabel(blurs, severity)],
},
}
}
function makeCustomLabel(
blurs: string,
severity: string,
): InterprettedLabelValueDefinition {
return interpretLabelValueDefinition({
identifier: 'custom',
blurs,
severity,
defaultSetting: 'warn',
locales: [],
})
}

@ -0,0 +1,276 @@
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { BskyAgent, BSKY_MODSERVICE_DID, DEFAULT_LABEL_SETTINGS } from '..'
import './util/moderation-behavior'
describe('agent', () => {
let network: TestNetworkNoAppView
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'bsky_agent',
})
})
afterAll(async () => {
await network.close()
})
it('migrates legacy content-label prefs (no mutations)', async () => {
const agent = new BskyAgent({ service: network.pds.url })
await agent.createAccount({
handle: 'user1.test',
email: 'user1@test.com',
password: 'password',
})
await agent.app.bsky.actor.putPreferences({
preferences: [
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nsfw',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'nudity',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'suggestive',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'gore',
visibility: 'show',
},
],
})
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: {
pinned: undefined,
saved: undefined,
},
hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: {
porn: 'ignore',
nudity: 'ignore',
sexual: 'ignore',
gore: 'ignore',
},
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideQuotePosts: false,
hideReplies: false,
hideRepliesByLikeCount: 0,
hideRepliesByUnfollowed: true,
hideReposts: false,
},
},
mutedWords: [],
threadViewPrefs: {
prioritizeFollowedUsers: true,
sort: 'oldest',
},
})
expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
})
it('adds/removes moderation services', async () => {
const agent = new BskyAgent({ service: network.pds.url })
await agent.createAccount({
handle: 'user5.test',
email: 'user5@test.com',
password: 'password',
})
await agent.addModService('did:plc:other')
expect(agent.labelersHeader).toStrictEqual([
BSKY_MODSERVICE_DID,
'did:plc:other',
])
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
{
did: 'did:plc:other',
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
expect(agent.labelersHeader).toStrictEqual([
BSKY_MODSERVICE_DID,
'did:plc:other',
])
await agent.removeModService('did:plc:other')
expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
})
it('cant remove the default moderation service', async () => {
const agent = new BskyAgent({ service: network.pds.url })
await agent.createAccount({
handle: 'user6.test',
email: 'user6@test.com',
password: 'password',
})
await agent.removeModService(BSKY_MODSERVICE_DID)
expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: DEFAULT_LABEL_SETTINGS,
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
expect(agent.labelersHeader).toStrictEqual([BSKY_MODSERVICE_DID])
})
it('sets label preferences globally and per-moderator', async () => {
const agent = new BskyAgent({ service: network.pds.url })
await agent.createAccount({
handle: 'user7.test',
email: 'user7@test.com',
password: 'password',
})
await agent.addModService('did:plc:other')
await agent.setContentLabelPref('porn', 'ignore')
await agent.setContentLabelPref('porn', 'hide', 'did:plc:other')
await agent.setContentLabelPref('x-custom', 'warn', 'did:plc:other')
await expect(agent.getPreferences()).resolves.toStrictEqual({
feeds: { pinned: undefined, saved: undefined },
hiddenPosts: [],
interests: { tags: [] },
moderationPrefs: {
adultContentEnabled: false,
labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore' },
mods: [
{
did: BSKY_MODSERVICE_DID,
labels: {},
},
{
did: 'did:plc:other',
labels: {
porn: 'hide',
'x-custom': 'warn',
},
},
],
},
birthDate: undefined,
feedViewPrefs: {
home: {
hideReplies: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
},
},
mutedWords: [],
threadViewPrefs: {
sort: 'oldest',
prioritizeFollowedUsers: true,
},
})
})
})

@ -1,5 +1,9 @@
import { moderateProfile, moderatePost } from '../src'
import { mock } from './util'
import {
moderateProfile,
moderatePost,
mock,
interpretLabelValueDefinition,
} from '../src'
import './util/moderation-behavior'
describe('Moderation', () => {
@ -20,25 +24,17 @@ describe('Moderation', () => {
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'hide',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'hide',
},
mods: [],
},
labelers: [],
},
)
expect(res1.account).toBeModerationResult(
{},
'post content',
JSON.stringify(res1, null, 2),
)
expect(res1.profile).toBeModerationResult(
{},
'post content',
JSON.stringify(res1, null, 2),
)
expect(res1.avatar).toBeModerationResult(
{ blur: true },
expect(res1.ui('avatar')).toBeModerationResult(
['blur'],
'post avatar',
JSON.stringify(res1, null, 2),
true,
@ -60,33 +56,150 @@ describe('Moderation', () => {
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'ignore',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'ignore',
},
mods: [],
},
labelers: [],
},
)
expect(res2.account).toBeModerationResult(
{},
'post content',
JSON.stringify(res2, null, 2),
)
expect(res2.profile).toBeModerationResult(
{},
'post content',
JSON.stringify(res2, null, 2),
)
expect(res2.avatar).toBeModerationResult(
{},
expect(res2.ui('avatar')).toBeModerationResult(
[],
'post avatar',
JSON.stringify(res2, null, 2),
JSON.stringify(res1, null, 2),
true,
)
})
it('Applies self-labels on posts according to the global preferences', () => {
// porn (hide)
it('Ignores labels from unsubscribed moderators or ignored labels for a moderator', () => {
// porn (moderator disabled)
const res1 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'hide',
},
mods: [],
},
},
)
for (const k of [
'profileList',
'profileView',
'avatar',
'banner',
'displayName',
'contentList',
'contentView',
'contentMedia',
]) {
expect(res1.ui(k)).toBeModerationResult(
[],
k,
JSON.stringify(res1, null, 2),
)
}
// porn (label group disabled)
const res2 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'ignore',
},
mods: [
{
did: 'did:web:labeler.test',
labels: { porn: 'ignore' },
},
],
},
},
)
for (const k of [
'profileList',
'profileView',
'avatar',
'banner',
'displayName',
'contentList',
'contentView',
'contentMedia',
]) {
expect(res2.ui(k)).toBeModerationResult(
[],
k,
JSON.stringify(res2, null, 2),
)
}
})
it('Can manually apply hiding', () => {
const res1 = moderatePost(
mock.postView({
record: {
text: 'Hello',
createdAt: new Date().toISOString(),
},
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
}),
labels: [],
}),
{
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: {},
mods: [
{
did: 'did:web:labeler.test',
labels: {},
},
],
},
},
)
res1.addHidden(true)
expect(res1.ui('contentList')).toBeModerationResult(
['filter', 'blur'],
'contentList',
)
expect(res1.ui('contentView')).toBeModerationResult(['blur'], 'contentView')
})
it('Prioritizes filters and blurs correctly on merge', () => {
const res1 = moderatePost(
mock.postView({
record: {
@ -99,41 +212,67 @@ describe('Moderation', () => {
}),
labels: [
{
src: 'did:web:bob.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.post/fake',
val: 'porn',
cts: new Date().toISOString(),
},
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.post/fake',
val: '!hide',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'hide',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'hide',
},
mods: [
{
did: 'did:web:labeler.test',
labels: {},
},
],
},
labelers: [],
},
)
expect(res1.content).toBeModerationResult(
{ cause: 'label:porn', filter: true },
'post content',
JSON.stringify(res1, null, 2),
)
expect(res1.embed).toBeModerationResult(
{ cause: 'label:porn', blur: true },
'post content',
JSON.stringify(res1, null, 2),
)
expect(res1.avatar).toBeModerationResult(
{},
'post avatar',
JSON.stringify(res1, null, 2),
true,
)
expect(res1.ui('contentList').filters[0].label.val).toBe('!hide')
expect(res1.ui('contentList').filters[1].label.val).toBe('porn')
expect(res1.ui('contentList').blurs[0].label.val).toBe('!hide')
expect(res1.ui('contentMedia').blurs[0].label.val).toBe('porn')
})
// porn (ignore)
const res2 = moderatePost(
it('Prioritizes custom label definitions', () => {
const modOpts = {
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: { porn: 'warn' },
mods: [
{
did: 'did:web:labeler.test',
labels: { porn: 'warn' },
},
],
},
labelDefs: {
'did:web:labeler.test': [
interpretLabelValueDefinition({
identifier: 'porn',
blurs: 'none',
severity: 'inform',
defaultSetting: 'warn',
locales: [],
}),
],
},
}
const res = moderatePost(
mock.postView({
record: {
text: 'Hello',
@ -145,190 +284,153 @@ describe('Moderation', () => {
}),
labels: [
{
src: 'did:web:bob.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.post/fake',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'ignore',
},
labelers: [],
},
)
expect(res2.content).toBeModerationResult(
{},
'post content',
JSON.stringify(res2, null, 2),
)
expect(res2.embed).toBeModerationResult(
{},
'post content',
JSON.stringify(res2, null, 2),
)
expect(res2.avatar).toBeModerationResult(
{},
'post avatar',
JSON.stringify(res2, null, 2),
true,
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(['inform'])
expect(res.ui('contentView')).toBeModerationResult(['inform'])
expect(res.ui('contentMedia')).toBeModerationResult([])
})
it('Applies labeler labels according to the per-labeler then global preferences', () => {
// porn (ignore for labeler, hide for global)
const res1 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
it('Doesnt allow custom behaviors to override imperative labels', () => {
const modOpts = {
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'hide',
},
labelers: [
labels: {},
mods: [
{
labeler: {
did: 'did:web:labeler.test',
displayName: 'Labeler',
},
labels: {
porn: 'ignore',
},
},
],
},
)
expect(res1.avatar).toBeModerationResult(
{},
'post avatar',
JSON.stringify(res1, null, 2),
true,
)
// porn (hide for labeler, ignore for global)
const res2 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'ignore',
},
labelers: [
{
labeler: {
did: 'did:web:labeler.test',
displayName: 'Labeler',
},
labels: {
porn: 'hide',
},
},
],
},
)
expect(res2.avatar).toBeModerationResult(
{ blur: true },
'post avatar',
JSON.stringify(res2, null, 2),
true,
)
// porn (unspecified for labeler, hide for global)
const res3 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labels: [
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
adultContentEnabled: true,
labels: {
porn: 'hide',
},
labelers: [
{
labeler: {
did: 'did:web:labeler.test',
displayName: 'Labeler',
},
did: 'did:web:labeler.test',
labels: {},
},
],
},
)
expect(res3.avatar).toBeModerationResult(
{ blur: true },
'post avatar',
JSON.stringify(res3, null, 2),
true,
)
})
/*
TODO enable when 3P labeler support is added
it('Ignores labels from unknown labelers', () => {
const res1 = moderateProfile(
mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
labelDefs: {
'did:web:labeler.test': [
interpretLabelValueDefinition({
identifier: '!hide',
blurs: 'none',
severity: 'inform',
defaultSetting: 'warn',
locales: [],
}),
],
},
}
const res = moderatePost(
mock.postView({
record: {
text: 'Hello',
createdAt: new Date().toISOString(),
},
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
}),
labels: [
{
src: 'did:web:rando.test',
uri: 'at://did:web:bob.test/app.bsky.actor.profile/self',
val: 'porn',
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.post/fake',
val: '!hide',
cts: new Date().toISOString(),
},
],
}),
{
userDid: 'did:web:alice.test',
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([])
})
it('Ignores invalid label value names', () => {
const modOpts = {
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: true,
labels: {
porn: 'hide',
},
labelers: [],
labels: {},
mods: [
{
did: 'did:web:labeler.test',
labels: { BadLabel: 'hide', 'bad/label': 'hide' },
},
],
},
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: [],
}),
],
},
}
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: 'BadLabel',
cts: new Date().toISOString(),
},
{
src: 'did:web:labeler.test',
uri: 'at://did:web:bob.test/app.bsky.post/fake',
val: 'bad/label',
cts: new Date().toISOString(),
},
],
}),
modOpts,
)
expect(res1.avatar).toBeModerationResult(
{},
'post avatar',
JSON.stringify(res1, null, 2),
true,
)
})*/
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([])
expect(res.ui('contentView')).toBeModerationResult([])
expect(res.ui('contentMedia')).toBeModerationResult([])
})
})

@ -1,46 +0,0 @@
import { moderatePost } from '../src'
import type {
ModerationBehaviors,
ModerationBehaviorScenario,
} from '../definitions/moderation-behaviors'
import { ModerationBehaviorSuiteRunner } from './util/moderation-behavior'
import { readFileSync } from 'fs'
import { join } from 'path'
const suite: ModerationBehaviors = JSON.parse(
readFileSync(
join(__dirname, '..', 'definitions', 'post-moderation-behaviors.json'),
'utf8',
),
)
const suiteRunner = new ModerationBehaviorSuiteRunner(suite)
describe('Post moderation behaviors', () => {
const scenarios = Array.from(Object.entries(suite.scenarios))
it.each(scenarios)(
'%s',
(_name: string, scenario: ModerationBehaviorScenario) => {
const res = moderatePost(
suiteRunner.postScenario(scenario),
suiteRunner.moderationOpts(scenario),
)
expect(res.content).toBeModerationResult(
scenario.behaviors.content,
'post content',
JSON.stringify(res, null, 2),
)
expect(res.avatar).toBeModerationResult(
scenario.behaviors.avatar,
'post avatar',
JSON.stringify(res, null, 2),
true,
)
expect(res.embed).toBeModerationResult(
scenario.behaviors.embed,
'post embed',
JSON.stringify(res, null, 2),
)
},
)
})

@ -1,46 +0,0 @@
import { moderateProfile } from '../src'
import type {
ModerationBehaviors,
ModerationBehaviorScenario,
} from '../definitions/moderation-behaviors'
import { ModerationBehaviorSuiteRunner } from './util/moderation-behavior'
import { readFileSync } from 'fs'
import { join } from 'path'
const suite: ModerationBehaviors = JSON.parse(
readFileSync(
join(__dirname, '..', 'definitions', 'profile-moderation-behaviors.json'),
'utf8',
),
)
const suiteRunner = new ModerationBehaviorSuiteRunner(suite)
describe('Post moderation behaviors', () => {
const scenarios = Array.from(Object.entries(suite.scenarios))
it.each(scenarios)(
'%s',
(_name: string, scenario: ModerationBehaviorScenario) => {
const res = moderateProfile(
suiteRunner.profileScenario(scenario),
suiteRunner.moderationOpts(scenario),
)
expect(res.account).toBeModerationResult(
scenario.behaviors.account,
'account',
JSON.stringify(res, null, 2),
)
expect(res.profile).toBeModerationResult(
scenario.behaviors.profile,
'profile content',
JSON.stringify(res, null, 2),
)
expect(res.avatar).toBeModerationResult(
scenario.behaviors.avatar,
'profile avatar',
JSON.stringify(res, null, 2),
true,
)
},
)
})

@ -0,0 +1,21 @@
import http from 'node:http'
export async function createHeaderEchoServer(port: number) {
return new Promise<http.Server>((resolve) => {
const server = http.createServer()
server
.on('request', (request, response) => {
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({
...request.headers,
did: 'did:web:fake.com',
availableUserDomains: [],
}),
)
})
.on('listening', () => resolve(server))
.listen(port)
})
}

@ -1,12 +1,4 @@
import {
AtpAgentFetchHandlerResponse,
ComAtprotoLabelDefs,
AppBskyFeedDefs,
AppBskyActorDefs,
AppBskyFeedPost,
AppBskyEmbedRecord,
AppBskyGraphDefs,
} from '../../src'
import { AtpAgentFetchHandlerResponse } from '../../src'
export async function fetchHandler(
httpUri: string,
@ -32,148 +24,3 @@ export async function fetchHandler(
body: resBody ? JSON.parse(new TextDecoder().decode(resBody)) : undefined,
}
}
export const mock = {
post({
text,
reply,
embed,
}: {
text: string
reply?: AppBskyFeedPost.ReplyRef
embed?: AppBskyFeedPost.Record['embed']
}): AppBskyFeedPost.Record {
return {
$type: 'app.bsky.feed.post',
text,
reply,
embed,
langs: ['en'],
createdAt: new Date().toISOString(),
}
},
postView({
record,
author,
embed,
replyCount,
repostCount,
likeCount,
viewer,
labels,
}: {
record: AppBskyFeedPost.Record
author: AppBskyActorDefs.ProfileViewBasic
embed?: AppBskyFeedDefs.PostView['embed']
replyCount?: number
repostCount?: number
likeCount?: number
viewer?: AppBskyFeedDefs.ViewerState
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyFeedDefs.PostView {
return {
uri: `at://${author.did}/app.bsky.post/fake`,
cid: 'fake',
author,
record,
embed,
replyCount,
repostCount,
likeCount,
indexedAt: new Date().toISOString(),
viewer,
labels,
}
},
embedRecordView({
record,
author,
labels,
}: {
record: AppBskyFeedPost.Record
author: AppBskyActorDefs.ProfileViewBasic
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyEmbedRecord.View {
return {
$type: 'app.bsky.embed.record#view',
record: {
$type: 'app.bsky.embed.record#viewRecord',
uri: `at://${author.did}/app.bsky.post/fake`,
cid: 'fake',
author,
value: record,
labels,
indexedAt: new Date().toISOString(),
},
}
},
profileViewBasic({
handle,
displayName,
viewer,
labels,
}: {
handle: string
displayName?: string
viewer?: AppBskyActorDefs.ViewerState
labels?: ComAtprotoLabelDefs.Label[]
}): AppBskyActorDefs.ProfileViewBasic {
return {
did: `did:web:${handle}`,
handle,
displayName,
viewer,
labels,
}
},
actorViewerState({
muted,
mutedByList,
blockedBy,
blocking,
blockingByList,
following,
followedBy,
}: {
muted?: boolean
mutedByList?: AppBskyGraphDefs.ListViewBasic
blockedBy?: boolean
blocking?: string
blockingByList?: AppBskyGraphDefs.ListViewBasic
following?: string
followedBy?: string
}): AppBskyActorDefs.ViewerState {
return {
muted,
mutedByList,
blockedBy,
blocking,
blockingByList,
following,
followedBy,
}
},
listViewBasic({ name }: { name: string }): AppBskyGraphDefs.ListViewBasic {
return {
uri: 'at://did:plc:fake/app.bsky.graph.list/fake',
cid: 'fake',
name,
purpose: 'app.bsky.graph.defs#modlist',
indexedAt: new Date().toISOString(),
}
},
label({ val, uri }: { val: string; uri: string }): ComAtprotoLabelDefs.Label {
return {
src: 'did:plc:fake-labeler',
uri,
val,
cts: new Date().toISOString(),
}
},
}

@ -1,38 +1,97 @@
import { ModerationUI, ModerationOpts, ComAtprotoLabelDefs } from '../../src'
import type {
ModerationBehaviors,
ModerationBehaviorScenario,
ModerationBehaviorResult,
} from '../../definitions/moderation-behaviors'
import { mock as m } from './index'
import {
ModerationUI,
ModerationOpts,
ComAtprotoLabelDefs,
LabelPreference,
} from '../../src'
import { mock as m } from '../../src/mocker'
export type ModerationTestSuiteResultFlag =
| 'filter'
| 'blur'
| 'alert'
| 'inform'
| 'noOverride'
export interface ModerationTestSuiteScenario {
cfg: string
subject: 'post' | 'profile' | 'userlist' | 'feedgen'
author: string
quoteAuthor?: string
labels: {
post?: string[]
profile?: string[]
account?: string[]
quotedPost?: string[]
quotedAccount?: string[]
}
behaviors: {
profileList?: ModerationTestSuiteResultFlag[]
profileView?: ModerationTestSuiteResultFlag[]
avatar?: ModerationTestSuiteResultFlag[]
banner?: ModerationTestSuiteResultFlag[]
displayName?: ModerationTestSuiteResultFlag[]
contentList?: ModerationTestSuiteResultFlag[]
contentView?: ModerationTestSuiteResultFlag[]
contentMedia?: ModerationTestSuiteResultFlag[]
}
}
export type SuiteUsers = Record<
string,
{
blocking: boolean
blockingByList: boolean
blockedBy: boolean
muted: boolean
mutedByList: boolean
}
>
export type SuiteConfigurations = Record<
string,
{
authed?: boolean
adultContentEnabled?: boolean
settings?: Record<string, LabelPreference>
}
>
export type SuiteScenarios = Record<string, ModerationTestSuiteScenario>
expect.extend({
toBeModerationResult(
actual: ModerationUI,
expected: ModerationBehaviorResult | undefined,
context: string,
stringifiedResult: string,
expected: ModerationTestSuiteResultFlag[] | undefined,
context: string = '',
stringifiedResult: string | undefined = undefined,
ignoreCause = false,
) {
const fail = (msg: string) => ({
pass: false,
message: () => `${msg}. Full result: ${stringifiedResult}`,
message: () =>
`${msg}.${
stringifiedResult ? ` Full result: ${stringifiedResult}` : ''
}`,
})
let cause = actual.cause?.type as string
if (actual.cause?.type === 'label') {
cause = `label:${actual.cause.labelDef.id}`
} else if (actual.cause?.type === 'muted') {
if (actual.cause.source.type === 'list') {
cause = 'muted-by-list'
}
} else if (actual.cause?.type === 'blocking') {
if (actual.cause.source.type === 'list') {
cause = 'blocking-by-list'
}
}
// let cause = actual.causes?.type as string
// if (actual.cause?.type === 'label') {
// cause = `label:${actual.cause.labelDef.id}`
// } else if (actual.cause?.type === 'muted') {
// if (actual.cause.source.type === 'list') {
// cause = 'muted-by-list'
// }
// } else if (actual.cause?.type === 'blocking') {
// if (actual.cause.source.type === 'list') {
// cause = 'blocking-by-list'
// }
// }
if (!expected) {
if (!ignoreCause && actual.cause) {
return fail(`${context} expected to be a no-op, got ${cause}`)
// if (!ignoreCause && actual.cause) {
// return fail(`${context} expected to be a no-op, got ${cause}`)
// }
if (actual.inform) {
return fail(`${context} expected to be a no-op, got inform=true`)
}
if (actual.alert) {
return fail(`${context} expected to be a no-op, got alert=true`)
@ -47,35 +106,47 @@ expect.extend({
return fail(`${context} expected to be a no-op, got noOverride=true`)
}
} else {
if (!ignoreCause && cause !== expected.cause) {
return fail(`${context} expected to be ${expected.cause}, got ${cause}`)
}
if (!!actual.alert !== !!expected.alert) {
// if (!ignoreCause && cause !== expected.cause) {
// return fail(`${context} expected to be ${expected.cause}, got ${cause}`)
// }
const expectedInform = expected.includes('inform')
if (!!actual.inform !== expectedInform) {
return fail(
`${context} expected to be alert=${expected.alert || false}, got ${
`${context} expected to be inform=${expectedInform}, got ${
actual.inform || false
}`,
)
}
const expectedAlert = expected.includes('alert')
if (!!actual.alert !== expectedAlert) {
return fail(
`${context} expected to be alert=${expectedAlert}, got ${
actual.alert || false
}`,
)
}
if (!!actual.blur !== !!expected.blur) {
const expectedBlur = expected.includes('blur')
if (!!actual.blur !== expectedBlur) {
return fail(
`${context} expected to be blur=${expected.blur || false}, got ${
`${context} expected to be blur=${expectedBlur}, got ${
actual.blur || false
}`,
)
}
if (!!actual.filter !== !!expected.filter) {
const expectedFilter = expected.includes('filter')
if (!!actual.filter !== expectedFilter) {
return fail(
`${context} expected to be filter=${expected.filter || false}, got ${
`${context} expected to be filter=${expectedFilter}, got ${
actual.filter || false
}`,
)
}
if (!!actual.noOverride !== !!expected.noOverride) {
const expectedNoOverride = expected.includes('noOverride')
if (!!actual.noOverride !== expectedNoOverride) {
return fail(
`${context} expected to be noOverride=${
expected.noOverride || false
}, got ${actual.noOverride || false}`,
`${context} expected to be noOverride=${expectedNoOverride}, got ${
actual.noOverride || false
}`,
)
}
}
@ -84,9 +155,13 @@ expect.extend({
})
export class ModerationBehaviorSuiteRunner {
constructor(public suite: ModerationBehaviors) {}
constructor(
public users: SuiteUsers,
public configurations: SuiteConfigurations,
public scenarios: SuiteScenarios,
) {}
postScenario(scenario: ModerationBehaviorScenario) {
postScenario(scenario: ModerationTestSuiteScenario) {
if (scenario.subject !== 'post') {
throw new Error('Scenario subject must be "post"')
}
@ -118,7 +193,7 @@ export class ModerationBehaviorSuiteRunner {
})
}
profileScenario(scenario: ModerationBehaviorScenario) {
profileScenario(scenario: ModerationTestSuiteScenario) {
if (scenario.subject !== 'profile') {
throw new Error('Scenario subject must be "profile"')
}
@ -127,9 +202,9 @@ export class ModerationBehaviorSuiteRunner {
profileViewBasic(
name: string,
scenarioLabels: ModerationBehaviorScenario['labels'],
scenarioLabels: ModerationTestSuiteScenario['labels'],
) {
const def = this.suite.users[name]
const def = this.users[name]
const labels: ComAtprotoLabelDefs.Label[] = []
if (scenarioLabels.account) {
@ -168,25 +243,24 @@ export class ModerationBehaviorSuiteRunner {
})
}
moderationOpts(scenario: ModerationBehaviorScenario): ModerationOpts {
moderationOpts(scenario: ModerationTestSuiteScenario): ModerationOpts {
return {
userDid:
this.suite.configurations[scenario.cfg].authed === false
this.configurations[scenario.cfg].authed === false
? ''
: 'did:web:self.test',
adultContentEnabled: Boolean(
this.suite.configurations[scenario.cfg].adultContentEnabled,
),
labels: this.suite.configurations[scenario.cfg].settings,
labelers: [
{
labeler: {
prefs: {
adultContentEnabled: Boolean(
this.configurations[scenario.cfg]?.adultContentEnabled,
),
labels: this.configurations[scenario.cfg].settings || {},
mods: [
{
did: 'did:plc:fake-labeler',
displayName: 'Fake Labeler',
labels: {},
},
labels: this.suite.configurations[scenario.cfg].settings,
},
],
],
},
}
}
}

@ -114,6 +114,14 @@ message GetThreadGateRecordsResponse {
repeated Record records = 1;
}
message GetLabelerRecordsRequest {
repeated string uris = 1;
}
message GetLabelerRecordsResponse {
repeated Record records = 1;
}
//
// Follows
@ -230,6 +238,8 @@ message GetCountsForUsersResponse {
repeated int32 reposts = 2;
repeated int32 following = 3;
repeated int32 followers = 4;
repeated int32 lists = 5;
repeated int32 feeds = 6;
}
//
@ -296,6 +306,7 @@ message ActorInfo {
bool taken_down = 4;
string takedown_ref = 5;
google.protobuf.Timestamp tombstoned_at = 6;
bool labeler = 7;
}
message GetActorsResponse {
@ -944,6 +955,7 @@ service Service {
rpc GetProfileRecords(GetProfileRecordsRequest) returns (GetProfileRecordsResponse);
rpc GetRepostRecords(GetRepostRecordsRequest) returns (GetRepostRecordsResponse);
rpc GetThreadGateRecords(GetThreadGateRecordsRequest) returns (GetThreadGateRecordsResponse);
rpc GetLabelerRecords(GetLabelerRecordsRequest) returns (GetLabelerRecordsResponse);
// Follows
rpc GetActorFollowsActors(GetActorFollowsActorsRequest) returns (GetActorFollowsActorsResponse);

@ -4,18 +4,24 @@ import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile
import AppContext from '../../../../context'
import { setRepoRev } from '../../../util'
import { createPipeline, noRules } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
export default function (server: Server, ctx: AppContext) {
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.actor.getProfile({
auth: ctx.authVerifier.optionalStandardOrRole,
handler: async ({ auth, params, res }) => {
handler: async ({ auth, params, req, res }) => {
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getProfile(
{ ...params, viewer, canViewTakedowns },
{ ...params, hydrateCtx, canViewTakedowns },
ctx,
)
@ -50,7 +56,7 @@ const hydration = async (input: {
const { ctx, params, skeleton } = input
return ctx.hydrator.hydrateProfilesDetailed(
[skeleton.did],
params.viewer,
params.hydrateCtx,
true,
)
}
@ -83,7 +89,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
canViewTakedowns: boolean
}

@ -4,17 +4,23 @@ import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile
import AppContext from '../../../../context'
import { setRepoRev } from '../../../util'
import { createPipeline, noRules } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
export default function (server: Server, ctx: AppContext) {
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.actor.getProfiles({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params, res }) => {
handler: async ({ auth, params, req, res }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { viewer, labelers }
const result = await getProfile({ ...params, viewer }, ctx)
const result = await getProfile({ ...params, hydrateCtx }, ctx)
const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer)
setRepoRev(res, repoRev)
@ -42,7 +48,7 @@ const hydration = async (input: {
skeleton: SkeletonState
}) => {
const { ctx, params, skeleton } = input
return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.viewer)
return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.hydrateCtx)
}
const presentation = (input: {
@ -64,7 +70,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
}
type SkeletonState = { dids: string[] }

@ -3,7 +3,11 @@ import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -17,9 +21,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.actor.getSuggestions({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getSuggestions({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { viewer, labelers }
const result = await getSuggestions({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
@ -34,19 +40,20 @@ const skeleton = async (input: {
params: Params
}): Promise<Skeleton> => {
const { ctx, params } = input
const viewer = params.hydrateCtx.viewer
// @NOTE for appview swap moving to rkey-based cursors which are somewhat permissive, should not hard-break pagination
const suggestions = await ctx.dataplane.getFollowSuggestions({
actorDid: params.viewer ?? undefined,
actorDid: viewer ?? undefined,
cursor: params.cursor,
limit: params.limit,
})
let dids = suggestions.dids
if (params.viewer !== null) {
if (viewer !== null) {
const follows = await ctx.dataplane.getActorFollowsActors({
actorDid: params.viewer,
actorDid: viewer,
targetDids: dids,
})
dids = dids.filter((did, i) => !follows.uris[i] && did !== params.viewer)
dids = dids.filter((did, i) => !follows.uris[i] && did !== viewer)
}
return { dids, cursor: parseString(suggestions.cursor) }
}
@ -59,7 +66,7 @@ const hydration = async (input: {
const { ctx, params, skeleton } = input
return ctx.hydrator.hydrateProfilesDetailed(
skeleton.dids,
params.viewer,
params.hydrateCtx,
true,
)
}
@ -102,7 +109,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
}
type Skeleton = { dids: string[]; cursor?: string }

@ -10,7 +10,7 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -24,9 +24,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.actor.searchActors({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const results = await searchActors({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { viewer, labelers }
const results = await searchActors({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: results,
@ -71,7 +73,7 @@ const hydration = async (
inputs: HydrationFnInput<Context, Params, Skeleton>,
) => {
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydrateProfiles(skeleton.dids, params.viewer)
return ctx.hydrator.hydrateProfiles(skeleton.dids, params.hydrateCtx)
}
const noBlocks = (inputs: RulesFnInput<Context, Params, Skeleton>) => {
@ -102,7 +104,7 @@ type Context = {
searchAgent?: AtpAgent
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
dids: string[]

@ -10,7 +10,7 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -24,9 +24,14 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.actor.searchActorsTypeahead({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const results = await searchActorsTypeahead({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const results = await searchActorsTypeahead(
{ ...params, hydrateCtx },
ctx,
)
return {
encoding: 'application/json',
body: results,
@ -70,7 +75,7 @@ const hydration = async (
inputs: HydrationFnInput<Context, Params, Skeleton>,
) => {
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydrateProfilesBasic(skeleton.dids, params.viewer)
return ctx.hydrator.hydrateProfilesBasic(skeleton.dids, params.hydrateCtx)
}
const noBlocks = (inputs: RulesFnInput<Context, Params, Skeleton>) => {
@ -100,7 +105,7 @@ type Context = {
searchAgent?: AtpAgent
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
dids: string[]

@ -4,7 +4,11 @@ import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorFeeds'
import AppContext from '../../../../context'
import { createPipeline, noRules } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -19,9 +23,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getActorFeeds({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const result = await getActorFeeds({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getActorFeeds({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -59,7 +65,10 @@ const hydration = async (inputs: {
skeleton: Skeleton
}) => {
const { ctx, params, skeleton } = inputs
return await ctx.hydrator.hydrateFeedGens(skeleton.feedUris, params.viewer)
return await ctx.hydrator.hydrateFeedGens(
skeleton.feedUris,
params.hydrateCtx,
)
}
const presentation = (inputs: {
@ -83,7 +92,7 @@ type Context = {
dataplane: DataPlaneClient
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
feedUris: string[]

@ -5,7 +5,11 @@ import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorLik
import AppContext from '../../../../context'
import { clearlyBadCursor, setRepoRev } from '../../../util'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -21,10 +25,12 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getActorLikes({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth, res }) => {
handler: async ({ params, auth, req, res }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getActorLikes({ ...params, viewer }, ctx)
const result = await getActorLikes({ ...params, hydrateCtx }, ctx)
const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer)
setRepoRev(res, repoRev)
@ -42,7 +48,8 @@ const skeleton = async (inputs: {
params: Params
}): Promise<Skeleton> => {
const { ctx, params } = inputs
const { actor, limit, cursor, viewer } = params
const { actor, limit, cursor } = params
const viewer = params.hydrateCtx.viewer
if (clearlyBadCursor(cursor)) {
return { items: [] }
}
@ -71,7 +78,7 @@ const hydration = async (inputs: {
skeleton: Skeleton
}) => {
const { ctx, params, skeleton } = inputs
return await ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer)
return await ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx)
}
const noPostBlocks = (inputs: {
@ -108,7 +115,7 @@ type Context = {
dataplane: DataPlaneClient
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
items: FeedItem[]

@ -6,6 +6,7 @@ import AppContext from '../../../../context'
import { clearlyBadCursor, setRepoRev } from '../../../util'
import { createPipeline } from '../../../../pipeline'
import {
HydrateCtx,
HydrationState,
Hydrator,
mergeStates,
@ -26,11 +27,13 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getAuthorFeed({
auth: ctx.authVerifier.optionalStandardOrRole,
handler: async ({ params, auth, res }) => {
handler: async ({ params, auth, req, res }) => {
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getAuthorFeed(
{ ...params, viewer, includeTakedowns: canViewTakedowns },
{ ...params, hydrateCtx, includeTakedowns: canViewTakedowns },
ctx,
)
@ -96,15 +99,13 @@ const hydration = async (inputs: {
skeleton: Skeleton
}): Promise<HydrationState> => {
const { ctx, params, skeleton } = inputs
const [feedPostState, profileViewerState = {}] = await Promise.all([
const [feedPostState, profileViewerState] = await Promise.all([
ctx.hydrator.hydrateFeedItems(
skeleton.items,
params.viewer,
params.hydrateCtx,
params.includeTakedowns,
),
params.viewer
? ctx.hydrator.hydrateProfileViewers([skeleton.actor.did], params.viewer)
: undefined,
ctx.hydrator.hydrateProfileViewers([skeleton.actor.did], params.hydrateCtx),
])
return mergeStates(feedPostState, profileViewerState)
}
@ -157,7 +158,10 @@ type Context = {
dataplane: DataPlaneClient
}
type Params = QueryParams & { viewer: string | null; includeTakedowns: boolean }
type Params = QueryParams & {
hydrateCtx: HydrateCtx
includeTakedowns: boolean
}
type Skeleton = {
actor: Actor

@ -19,6 +19,7 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { HydrateCtx } from '../../../../hydration/hydrator'
import { FeedItem } from '../../../../hydration/feed'
import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb'
import {
@ -39,13 +40,15 @@ export default function (server: Server, ctx: AppContext) {
auth: ctx.authVerifier.standardOptionalAnyAud,
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const headers = noUndefinedVals({
authorization: req.headers['authorization'],
'accept-language': req.headers['accept-language'],
})
// @NOTE feed cursors should not be affected by appview swap
const { timerSkele, timerHydr, resHeaders, ...result } = await getFeed(
{ ...params, viewer, headers },
{ ...params, hydrateCtx, headers },
ctx,
)
@ -90,7 +93,7 @@ const hydration = async (
const timerHydr = new ServerTimer('hydr').start()
const hydration = await ctx.hydrator.hydrateFeedItems(
skeleton.items,
params.viewer,
params.hydrateCtx,
)
skeleton.timerHydr = timerHydr.stop()
return hydration
@ -130,7 +133,7 @@ const presentation = (
type Context = AppContext
type Params = GetFeedParams & {
viewer: string | null
hydrateCtx: HydrateCtx
headers: Record<string, string>
}

@ -12,11 +12,15 @@ import {
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getFeedGenerator({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const { feed } = params
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydration = await ctx.hydrator.hydrateFeedGens([feed], viewer)
const hydration = await ctx.hydrator.hydrateFeedGens([feed], {
viewer,
labelers,
})
const feedInfo = hydration.feedgens?.get(feed)
if (!feedInfo) {
throw new InvalidRequestError('could not find feed')

@ -3,7 +3,11 @@ import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getFeedGenerators'
import AppContext from '../../../../context'
import { createPipeline, noRules } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
export default function (server: Server, ctx: AppContext) {
@ -15,9 +19,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getFeedGenerators({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const view = await getFeedGenerators({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const view = await getFeedGenerators({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: view,
@ -38,7 +44,10 @@ const hydration = async (inputs: {
skeleton: Skeleton
}) => {
const { ctx, params, skeleton } = inputs
return await ctx.hydrator.hydrateFeedGens(skeleton.feedUris, params.viewer)
return await ctx.hydrator.hydrateFeedGens(
skeleton.feedUris,
params.hydrateCtx,
)
}
const presentation = (inputs: {
@ -60,7 +69,7 @@ type Context = {
views: Views
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
feedUris: string[]

@ -4,7 +4,11 @@ import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes'
import AppContext from '../../../../context'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { parseString } from '../../../../hydration/util'
import { creatorFromUri } from '../../../../views/util'
@ -14,9 +18,11 @@ export default function (server: Server, ctx: AppContext) {
const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation)
server.app.bsky.feed.getLikes({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getLikes({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getLikes({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
@ -51,7 +57,7 @@ const hydration = async (inputs: {
skeleton: Skeleton
}) => {
const { ctx, params, skeleton } = inputs
return await ctx.hydrator.hydrateLikes(skeleton.likes, params.viewer)
return await ctx.hydrator.hydrateLikes(skeleton.likes, params.hydrateCtx)
}
const noBlocks = (inputs: {
@ -103,7 +109,7 @@ type Context = {
views: Views
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
likes: string[]

@ -3,7 +3,11 @@ import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getListFeed
import AppContext from '../../../../context'
import { clearlyBadCursor, setRepoRev } from '../../../util'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { mapDefined } from '@atproto/common'
@ -19,10 +23,12 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getListFeed({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth, res }) => {
handler: async ({ params, auth, req, res }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getListFeed({ ...params, viewer }, ctx)
const result = await getListFeed({ ...params, hydrateCtx }, ctx)
const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer)
setRepoRev(res, repoRev)
@ -65,7 +71,7 @@ const hydration = async (inputs: {
skeleton: Skeleton
}): Promise<HydrationState> => {
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer)
return ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx)
}
const noBlocksOrMutes = (inputs: {
@ -104,7 +110,7 @@ type Context = {
dataplane: DataPlaneClient
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
items: FeedItem[]

@ -14,7 +14,7 @@ import {
createPipeline,
noRules,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient, isDataplaneError, Code } from '../../../../data-plane'
@ -27,12 +27,14 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getPostThread({
auth: ctx.authVerifier.optionalStandardOrRole,
handler: async ({ params, auth, res }) => {
handler: async ({ params, auth, req, res }) => {
const { viewer } = ctx.authVerifier.parseCreds(auth)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
let result: OutputSchema
try {
result = await getPostThread({ ...params, viewer }, ctx)
result = await getPostThread({ ...params, hydrateCtx }, ctx)
} catch (err) {
const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer)
setRepoRev(res, repoRev)
@ -80,7 +82,7 @@ const hydration = async (
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydrateThreadPosts(
skeleton.uris.map((uri) => ({ uri })),
params.viewer,
params.hydrateCtx,
)
}
@ -105,7 +107,7 @@ type Context = {
views: Views
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
anchor: string

@ -3,7 +3,11 @@ import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts'
import AppContext from '../../../../context'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { creatorFromUri } from '../../../../views/util'
@ -11,9 +15,12 @@ export default function (server: Server, ctx: AppContext) {
const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation)
server.app.bsky.feed.getPosts({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const results = await getPosts({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const results = await getPosts({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
@ -35,7 +42,7 @@ const hydration = async (inputs: {
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydratePosts(
skeleton.posts.map((uri) => ({ uri })),
params.viewer,
params.hydrateCtx,
)
}
@ -70,7 +77,7 @@ type Context = {
views: Views
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
posts: string[]

@ -3,7 +3,11 @@ import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getRepostedBy'
import AppContext from '../../../../context'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { parseString } from '../../../../hydration/util'
import { creatorFromUri } from '../../../../views/util'
@ -18,9 +22,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getRepostedBy({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getRepostedBy({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getRepostedBy({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
@ -55,7 +61,7 @@ const hydration = async (inputs: {
skeleton: Skeleton
}) => {
const { ctx, params, skeleton } = inputs
return await ctx.hydrator.hydrateReposts(skeleton.reposts, params.viewer)
return await ctx.hydrator.hydrateReposts(skeleton.reposts, params.hydrateCtx)
}
const noBlocks = (inputs: {
@ -99,7 +105,7 @@ type Context = {
views: Views
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
reposts: string[]

@ -6,8 +6,9 @@ import { parseString } from '../../../../hydration/util'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getSuggestedFeeds({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
// @NOTE no need to coordinate the cursor for appview swap, as v1 doesn't use the cursor
const suggestedRes = await ctx.dataplane.getSuggestedFeeds({
@ -16,7 +17,10 @@ export default function (server: Server, ctx: AppContext) {
cursor: params.cursor,
})
const uris = suggestedRes.uris
const hydration = await ctx.hydrator.hydrateFeedGens(uris, viewer)
const hydration = await ctx.hydrator.hydrateFeedGens(uris, {
labelers,
viewer,
})
const feedViews = mapDefined(uris, (uri) =>
ctx.views.feedGenerator(uri, hydration),
)

@ -3,7 +3,11 @@ import AppContext from '../../../../context'
import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getTimeline'
import { clearlyBadCursor, setRepoRev } from '../../../util'
import { createPipeline } from '../../../../pipeline'
import { HydrationState, Hydrator } from '../../../../hydration/hydrator'
import {
HydrateCtx,
HydrationState,
Hydrator,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -19,10 +23,12 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.getTimeline({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth, res }) => {
handler: async ({ params, auth, req, res }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getTimeline({ ...params, viewer }, ctx)
const result = await getTimeline({ ...params, hydrateCtx }, ctx)
const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer)
setRepoRev(res, repoRev)
@ -44,7 +50,7 @@ export const skeleton = async (inputs: {
return { items: [] }
}
const res = await ctx.dataplane.getTimeline({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
limit: params.limit,
cursor: params.cursor,
})
@ -65,7 +71,7 @@ const hydration = async (inputs: {
skeleton: Skeleton
}): Promise<HydrationState> => {
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer)
return ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx)
}
const noBlocksOrMutes = (inputs: {
@ -104,7 +110,7 @@ type Context = {
dataplane: DataPlaneClient
}
type Params = QueryParams & { viewer: string }
type Params = QueryParams & { hydrateCtx: HydrateCtx & { viewer: string } }
type Skeleton = {
items: FeedItem[]

@ -10,7 +10,7 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { DataPlaneClient } from '../../../../data-plane'
import { parseString } from '../../../../hydration/util'
@ -25,9 +25,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.feed.searchPosts({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const results = await searchPosts({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const results = await searchPosts({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: results,
@ -70,7 +72,7 @@ const hydration = async (
const { ctx, params, skeleton } = inputs
return ctx.hydrator.hydratePosts(
skeleton.posts.map((uri) => ({ uri })),
params.viewer,
params.hydrateCtx,
)
}
@ -104,7 +106,7 @@ type Context = {
searchAgent?: AtpAgent
}
type Params = QueryParams & { viewer: string | null }
type Params = QueryParams & { hydrateCtx: HydrateCtx }
type Skeleton = {
posts: string[]

@ -9,7 +9,7 @@ import {
PresentationFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -17,9 +17,11 @@ export default function (server: Server, ctx: AppContext) {
const getBlocks = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.graph.getBlocks({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getBlocks({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getBlocks({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -34,7 +36,7 @@ const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
return { blockedDids: [] }
}
const { blockUris, cursor } = await ctx.hydrator.dataplane.getBlocks({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
cursor: params.cursor,
limit: params.limit,
})
@ -53,9 +55,7 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { blockedDids } = skeleton
return ctx.hydrator.hydrateProfiles(blockedDids, viewer)
return ctx.hydrator.hydrateProfiles(skeleton.blockedDids, params.hydrateCtx)
}
const presentation = (
@ -75,7 +75,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -11,7 +11,11 @@ import {
createPipeline,
} from '../../../../pipeline'
import { didFromUri } from '../../../../hydration/util'
import { Hydrator, mergeStates } from '../../../../hydration/hydrator'
import {
HydrateCtx,
Hydrator,
mergeStates,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -24,11 +28,13 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.graph.getFollowers({
auth: ctx.authVerifier.optionalStandardOrRole,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getFollowers(
{ ...params, viewer, canViewTakedowns },
{ ...params, hydrateCtx, canViewTakedowns },
ctx,
)
@ -65,7 +71,6 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { followUris, subjectDid } = skeleton
const followState = await ctx.hydrator.hydrateFollows(followUris)
const dids = [subjectDid]
@ -76,13 +81,16 @@ const hydration = async (
}
}
}
const profileState = await ctx.hydrator.hydrateProfiles(dids, viewer)
const profileState = await ctx.hydrator.hydrateProfiles(
dids,
params.hydrateCtx,
)
return mergeStates(followState, profileState)
}
const noBlocks = (input: RulesFnInput<Context, Params, SkeletonState>) => {
const { skeleton, params, hydration, ctx } = input
const { viewer } = params
const viewer = params.hydrateCtx.viewer
skeleton.followUris = skeleton.followUris.filter((followUri) => {
const followerDid = didFromUri(followUri)
return (
@ -123,7 +131,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
canViewTakedowns: boolean
}

@ -10,7 +10,11 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { Hydrator, mergeStates } from '../../../../hydration/hydrator'
import {
HydrateCtx,
Hydrator,
mergeStates,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -18,12 +22,14 @@ export default function (server: Server, ctx: AppContext) {
const getFollows = createPipeline(skeleton, hydration, noBlocks, presentation)
server.app.bsky.graph.getFollows({
auth: ctx.authVerifier.optionalStandardOrRole,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
// @TODO ensure canViewTakedowns gets threaded through and applied properly
const result = await getFollows(
{ ...params, viewer, canViewTakedowns },
{ ...params, hydrateCtx, canViewTakedowns },
ctx,
)
@ -60,7 +66,6 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { followUris, subjectDid } = skeleton
const followState = await ctx.hydrator.hydrateFollows(followUris)
const dids = [subjectDid]
@ -71,13 +76,16 @@ const hydration = async (
}
}
}
const profileState = await ctx.hydrator.hydrateProfiles(dids, viewer)
const profileState = await ctx.hydrator.hydrateProfiles(
dids,
params.hydrateCtx,
)
return mergeStates(followState, profileState)
}
const noBlocks = (input: RulesFnInput<Context, Params, SkeletonState>) => {
const { skeleton, params, hydration, ctx } = input
const { viewer } = params
const viewer = params.hydrateCtx.viewer
skeleton.followUris = skeleton.followUris.filter((followUri) => {
const follow = hydration.follows?.get(followUri)
if (!follow) return false
@ -121,7 +129,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
canViewTakedowns: boolean
}

@ -10,7 +10,11 @@ import {
PresentationFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator, mergeStates } from '../../../../hydration/hydrator'
import {
HydrateCtx,
Hydrator,
mergeStates,
} from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
import { ListItemInfo } from '../../../../proto/bsky_pb'
@ -19,9 +23,11 @@ export default function (server: Server, ctx: AppContext) {
const getList = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.graph.getList({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getList({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getList({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -53,13 +59,12 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { listUri, listitems } = skeleton
const [listState, profileState] = await Promise.all([
ctx.hydrator.hydrateLists([listUri], viewer),
ctx.hydrator.hydrateLists([listUri], params.hydrateCtx),
ctx.hydrator.hydrateProfiles(
listitems.map(({ did }) => did),
viewer,
params.hydrateCtx,
),
])
return mergeStates(listState, profileState)
@ -88,7 +93,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
}
type SkeletonState = {

@ -9,7 +9,7 @@ import {
PresentationFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -22,9 +22,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.graph.getListBlocks({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getListBlocks({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getListBlocks({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -42,7 +44,7 @@ const skeleton = async (
}
const { listUris, cursor } =
await ctx.hydrator.dataplane.getBlocklistSubscriptions({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
cursor: params.cursor,
limit: params.limit,
})
@ -53,7 +55,7 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
return await ctx.hydrator.hydrateLists(skeleton.listUris, params.viewer)
return await ctx.hydrator.hydrateLists(skeleton.listUris, params.hydrateCtx)
}
const presentation = (
@ -71,7 +73,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -9,7 +9,7 @@ import {
PresentationFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -22,9 +22,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.graph.getListMutes({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getListMutes({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getListMutes({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -42,7 +44,7 @@ const skeleton = async (
}
const { listUris, cursor } =
await ctx.hydrator.dataplane.getMutelistSubscriptions({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
cursor: params.cursor,
limit: params.limit,
})
@ -53,7 +55,7 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
return await ctx.hydrator.hydrateLists(skeleton.listUris, params.viewer)
return await ctx.hydrator.hydrateLists(skeleton.listUris, params.hydrateCtx)
}
const presentation = (
@ -71,7 +73,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -9,7 +9,7 @@ import {
PresentationFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { clearlyBadCursor } from '../../../util'
@ -17,9 +17,11 @@ export default function (server: Server, ctx: AppContext) {
const getLists = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.graph.getLists({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getLists({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getLists({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
@ -48,9 +50,8 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { listUris } = skeleton
return ctx.hydrator.hydrateLists(listUris, viewer)
return ctx.hydrator.hydrateLists(listUris, params.hydrateCtx)
}
const presentation = (
@ -70,7 +71,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string | null
hydrateCtx: HydrateCtx
}
type SkeletonState = {

@ -2,7 +2,7 @@ import { mapDefined } from '@atproto/common'
import { Server } from '../../../../lexicon'
import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getMutes'
import AppContext from '../../../../context'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import {
HydrationFnInput,
@ -17,9 +17,11 @@ export default function (server: Server, ctx: AppContext) {
const getMutes = createPipeline(skeleton, hydration, noRules, presentation)
server.app.bsky.graph.getMutes({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await getMutes({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getMutes({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -34,7 +36,7 @@ const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
return { mutedDids: [] }
}
const { dids, cursor } = await ctx.hydrator.dataplane.getMutes({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
cursor: params.cursor,
limit: params.limit,
})
@ -48,9 +50,8 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { mutedDids } = skeleton
return ctx.hydrator.hydrateProfiles(mutedDids, viewer)
return ctx.hydrator.hydrateProfiles(mutedDids, params.hydrateCtx)
}
const presentation = (
@ -70,7 +71,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -10,7 +10,7 @@ import {
SkeletonFnInput,
createPipeline,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
export default function (server: Server, ctx: AppContext) {
@ -22,10 +22,12 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.graph.getSuggestedFollowsByActor({
auth: ctx.authVerifier.standard,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await getSuggestedFollowsByActor(
{ ...params, viewer },
{ ...params, hydrateCtx },
ctx,
)
return {
@ -43,7 +45,7 @@ const skeleton = async (input: SkeletonFnInput<Context, Params>) => {
throw new InvalidRequestError('Actor not found')
}
const { dids, cursor } = await ctx.hydrator.dataplane.getFollowSuggestions({
actorDid: params.viewer,
actorDid: params.hydrateCtx.viewer,
relativeToDid,
})
return {
@ -56,9 +58,8 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { ctx, params, skeleton } = input
const { viewer } = params
const { suggestedDids } = skeleton
return ctx.hydrator.hydrateProfilesDetailed(suggestedDids, viewer)
return ctx.hydrator.hydrateProfilesDetailed(suggestedDids, params.hydrateCtx)
}
const noBlocksOrMutes = (
@ -90,7 +91,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -6,7 +6,7 @@ import { MuteOperation_Type } from '../../../../proto/bsync_pb'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.muteActor({
auth: ctx.authVerifier.standard,
handler: async ({ req, auth, input }) => {
handler: async ({ auth, input }) => {
const { actor } = input.body
const requester = auth.credentials.iss
const [did] = await ctx.hydrator.actor.getDids([actor])

@ -0,0 +1,44 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { mapDefined } from '@atproto/common'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.labeler.getServices({
auth: ctx.authVerifier.standardOptional,
handler: async ({ params, auth, req }) => {
const { dids, detailed } = params
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydration = await ctx.hydrator.hydrateLabelers(dids, {
viewer,
labelers,
})
const views = mapDefined(dids, (did) => {
if (detailed) {
const view = ctx.views.labelerDetailed(did, hydration)
if (!view) return
return {
$type: 'app.bsky.labeler.defs#labelerViewDetailed',
...view,
}
} else {
const view = ctx.views.labeler(did, hydration)
if (!view) return
return {
$type: 'app.bsky.labeler.defs#labelerView',
...view,
}
}
})
return {
encoding: 'application/json',
body: {
views,
},
}
},
})
}

@ -10,7 +10,7 @@ import {
RulesFnInput,
SkeletonFnInput,
} from '../../../../pipeline'
import { Hydrator } from '../../../../hydration/hydrator'
import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator'
import { Views } from '../../../../views'
import { Notification } from '../../../../proto/bsky_pb'
import { didFromUri } from '../../../../hydration/util'
@ -25,9 +25,11 @@ export default function (server: Server, ctx: AppContext) {
)
server.app.bsky.notification.listNotifications({
auth: ctx.authVerifier.standard,
handler: async ({ params, auth }) => {
handler: async ({ params, auth, req }) => {
const viewer = auth.credentials.iss
const result = await listNotifications({ ...params, viewer }, ctx)
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { labelers, viewer }
const result = await listNotifications({ ...params, hydrateCtx }, ctx)
return {
encoding: 'application/json',
body: result,
@ -43,17 +45,18 @@ const skeleton = async (
if (params.seenAt) {
throw new InvalidRequestError('The seenAt parameter is unsupported')
}
const viewer = params.hydrateCtx.viewer
if (clearlyBadCursor(params.cursor)) {
return { notifs: [] }
}
const [res, lastSeenRes] = await Promise.all([
ctx.hydrator.dataplane.getNotifications({
actorDid: params.viewer,
actorDid: viewer,
cursor: params.cursor,
limit: params.limit,
}),
ctx.hydrator.dataplane.getNotificationSeen({
actorDid: params.viewer,
actorDid: viewer,
}),
])
// @NOTE for the first page of results if there's no last-seen time, consider top notification unread
@ -73,7 +76,7 @@ const hydration = async (
input: HydrationFnInput<Context, Params, SkeletonState>,
) => {
const { skeleton, params, ctx } = input
return ctx.hydrator.hydrateNotifications(skeleton.notifs, params.viewer)
return ctx.hydrator.hydrateNotifications(skeleton.notifs, params.hydrateCtx)
}
const noBlockOrMutes = (
@ -107,7 +110,7 @@ type Context = {
}
type Params = QueryParams & {
viewer: string
hydrateCtx: HydrateCtx & { viewer: string }
}
type SkeletonState = {

@ -10,8 +10,10 @@ import { clearlyBadCursor } from '../../../util'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.unspecced.getPopularFeedGenerators({
auth: ctx.authVerifier.standardOptional,
handler: async ({ auth, params }) => {
handler: async ({ auth, params, req }) => {
const viewer = auth.credentials.iss
const labelers = ctx.reqLabelers(req)
const hydrateCtx = { viewer, labelers }
if (clearlyBadCursor(params.cursor)) {
return {
@ -40,7 +42,7 @@ export default function (server: Server, ctx: AppContext) {
cursor = parseString(res.cursor)
}
const hydration = await ctx.hydrator.hydrateFeedGens(uris, viewer)
const hydration = await ctx.hydrator.hydrateFeedGens(uris, hydrateCtx)
const feedViews = mapDefined(uris, (uri) =>
ctx.views.feedGenerator(uri, hydration),
)

@ -30,6 +30,7 @@ import unmuteActor from './app/bsky/graph/unmuteActor'
import muteActorList from './app/bsky/graph/muteActorList'
import unmuteActorList from './app/bsky/graph/unmuteActorList'
import getSuggestedFollowsByActor from './app/bsky/graph/getSuggestedFollowsByActor'
import getLabelerServices from './app/bsky/labeler/getServices'
import searchActors from './app/bsky/actor/searchActors'
import searchActorsTypeahead from './app/bsky/actor/searchActorsTypeahead'
import getSuggestions from './app/bsky/actor/getSuggestions'
@ -84,6 +85,7 @@ export default function (server: Server, ctx: AppContext) {
muteActorList(server, ctx)
unmuteActorList(server, ctx)
getSuggestedFollowsByActor(server, ctx)
getLabelerServices(server, ctx)
searchActors(server, ctx)
searchActorsTypeahead(server, ctx)
getSuggestions(server, ctx)

Some files were not shown because too many files have changed in this diff Show More