From 39885432580466fa7eccc795565867a301ca613d Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 6 Mar 2024 17:56:34 -0600 Subject: [PATCH] Feature - 3rd party labelers (#2032) * 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 (#2033) 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 (#2017) handle empty cursor on appview listnotifs * Update appview v2 to use author feed enum (#2047) * 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 (#2050) utilize new sorted-at field in bsky protos * remove all dataplane usage of GetLikeCounts, switch to GetInteractionCounts * Appview v2, sync w/ changes to protos (#2071) * sync bsky protos * sync-up bsky implementation w/ proto changes * Appview v2 initial implementation for getPopularFeedGenerators (#2072) 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 (#2092) * 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 (#2096) * alter behavior for presenting notifications w/ no last-seen time * fix pds proxy tests * Appview v2 dataplane retries based on client host (#2098) 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 (#2095) * 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 (#2107) apply safe takedown refs to records, actors * Fix timing on appview v2 repo rev header (#2113) fix timing on appview repo rev * fix post thread responses * Appview v2 don't apply 3p self blocks (#2112) do not apply 3p self-blocks * Appview v2 search for feed generators (#2118) * add protos for feedgen search * support feed search on getPopularFeedGenerators * Appview v2 config tidy (#2117) * 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 (#2122) * dont apply missing mod lists * update mock dataplane * Update packages/bsky/src/hydration/hydrator.ts Co-authored-by: devin ivy * refactor & document a bit better * fix up other routes --------- Co-authored-by: devin ivy * Appview v2 enforce post thread root boundary (#2120) * enforce post thread root boundary * test thread root boundary * Appview v2 fix admin environment variable (#2137) fix admin env in appview v2 * Remove re-pagination from getSuggestions (#2145) * remove re-pagination from getSuggestions * fix test * Adjust wording for account suspension (#2153) adjust wording for account suspension * Appview v2: fix not-found and blocked uris in threads (#2201) * fix uris of not-found and blocked posts in threads * update snaps * :sparkles: Show author feed of takendown author to admins only (#2197) * 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 (#2176) * 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 (#2258) * 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 Co-authored-by: Paul Frazee Co-authored-by: Foysal Ahamed --- lexicons/app/bsky/actor/defs.json | 42 +- lexicons/app/bsky/embed/record.json | 3 +- lexicons/app/bsky/labeler/defs.json | 70 + lexicons/app/bsky/labeler/getServices.json | 43 + lexicons/app/bsky/labeler/service.json | 26 + lexicons/com/atproto/label/defs.json | 67 + packages/api/definitions/labels.json | 380 ++-- .../definitions/locale/en/label-groups.json | 38 - .../api/definitions/locale/en/labels.json | 394 ---- .../locale/en/proposed-label-groups.json | 38 - .../locale/en/proposed-labels.json | 632 ------ .../api/definitions/moderation-behaviors.d.ts | 50 - .../post-moderation-behaviors.json | 1222 ----------- .../profile-moderation-behaviors.json | 597 ------ packages/api/definitions/proposed-labels.json | 326 --- packages/api/docs/labels.md | 490 +---- .../api/docs/moderation-behaviors/posts.md | 1813 ----------------- .../api/docs/moderation-behaviors/profiles.md | 833 -------- packages/api/docs/moderation.md | 2 - packages/api/scripts/code/label-groups.mjs | 68 - packages/api/scripts/code/labels.mjs | 52 +- packages/api/scripts/docs/labels.mjs | 93 +- .../docs/post-moderation-behaviors.mjs | 117 -- .../docs/profile-moderation-behaviors.mjs | 122 -- packages/api/scripts/generate-code.mjs | 1 - packages/api/scripts/generate-docs.mjs | 2 - packages/api/src/agent.ts | 28 +- packages/api/src/bsky-agent.ts | 208 +- packages/api/src/client/index.ts | 99 + packages/api/src/client/lexicons.ts | 322 ++- .../src/client/types/app/bsky/actor/defs.ts | 58 +- .../src/client/types/app/bsky/embed/record.ts | 2 + .../src/client/types/app/bsky/labeler/defs.ts | 93 + .../types/app/bsky/labeler/getServices.ts | 41 + .../client/types/app/bsky/labeler/service.ts | 31 + .../client/types/com/atproto/label/defs.ts | 68 + packages/api/src/const.ts | 1 + packages/api/src/index.ts | 5 +- packages/api/src/mocker.ts | 214 ++ packages/api/src/moderation/accumulator.ts | 217 -- .../api/src/moderation/const/label-groups.ts | 149 -- packages/api/src/moderation/const/labels.ts | 1094 +++------- packages/api/src/moderation/decision.ts | 337 +++ packages/api/src/moderation/index.ts | 342 +--- .../api/src/moderation/subjects/account.ts | 16 +- .../src/moderation/subjects/feed-generator.ts | 9 +- .../src/moderation/subjects/notification.ts | 19 + packages/api/src/moderation/subjects/post.ts | 16 +- .../api/src/moderation/subjects/profile.ts | 17 +- .../src/moderation/subjects/quoted-post.ts | 80 - .../api/src/moderation/subjects/user-list.ts | 9 +- packages/api/src/moderation/types.ts | 158 +- packages/api/src/moderation/ui.ts | 21 + packages/api/src/moderation/util.ts | 154 +- packages/api/src/types.ts | 13 +- packages/api/tests/agent.test.ts | 20 + packages/api/tests/bsky-agent.test.ts | 499 ++++- .../api/tests/moderation-behaviors.test.ts | 969 +++++++++ .../tests/moderation-custom-labels.test.ts | 358 ++++ packages/api/tests/moderation-prefs.test.ts | 276 +++ packages/api/tests/moderation.test.ts | 504 +++-- packages/api/tests/post-moderation.test.ts | 46 - packages/api/tests/profile-moderation.test.ts | 46 - packages/api/tests/util/echo-server.ts | 21 + packages/api/tests/util/index.ts | 155 +- .../api/tests/util/moderation-behavior.ts | 184 +- packages/bsky/proto/bsky.proto | 12 + .../bsky/src/api/app/bsky/actor/getProfile.ts | 16 +- .../src/api/app/bsky/actor/getProfiles.ts | 16 +- .../src/api/app/bsky/actor/getSuggestions.ts | 25 +- .../src/api/app/bsky/actor/searchActors.ts | 12 +- .../app/bsky/actor/searchActorsTypeahead.ts | 15 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 19 +- .../src/api/app/bsky/feed/getActorLikes.ts | 19 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 20 +- .../bsky/src/api/app/bsky/feed/getFeed.ts | 9 +- .../src/api/app/bsky/feed/getFeedGenerator.ts | 8 +- .../api/app/bsky/feed/getFeedGenerators.ts | 19 +- .../bsky/src/api/app/bsky/feed/getLikes.ts | 16 +- .../bsky/src/api/app/bsky/feed/getListFeed.ts | 16 +- .../src/api/app/bsky/feed/getPostThread.ts | 12 +- .../bsky/src/api/app/bsky/feed/getPosts.ts | 17 +- .../src/api/app/bsky/feed/getRepostedBy.ts | 16 +- .../api/app/bsky/feed/getSuggestedFeeds.ts | 8 +- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 18 +- .../bsky/src/api/app/bsky/feed/searchPosts.ts | 12 +- .../bsky/src/api/app/bsky/graph/getBlocks.ts | 16 +- .../src/api/app/bsky/graph/getFollowers.ts | 22 +- .../bsky/src/api/app/bsky/graph/getFollows.ts | 22 +- .../bsky/src/api/app/bsky/graph/getList.ts | 19 +- .../src/api/app/bsky/graph/getListBlocks.ts | 14 +- .../src/api/app/bsky/graph/getListMutes.ts | 14 +- .../bsky/src/api/app/bsky/graph/getLists.ts | 13 +- .../bsky/src/api/app/bsky/graph/getMutes.ts | 15 +- .../bsky/graph/getSuggestedFollowsByActor.ts | 15 +- .../bsky/src/api/app/bsky/graph/muteActor.ts | 2 +- .../src/api/app/bsky/labeler/getServices.ts | 44 + .../bsky/notification/listNotifications.ts | 17 +- .../unspecced/getPopularFeedGenerators.ts | 6 +- packages/bsky/src/api/index.ts | 2 + packages/bsky/src/context.ts | 13 + .../data-plane/server/db/database-schema.ts | 6 +- .../20240226T225725627Z-labelers.ts | 27 + .../data-plane/server/db/migrations/index.ts | 1 + .../data-plane/server/db/tables/labeler.ts | 16 + .../src/data-plane/server/indexing/index.ts | 4 + .../server/indexing/plugins/labeler.ts | 77 + .../data-plane/server/routes/interactions.ts | 18 +- .../src/data-plane/server/routes/profile.ts | 16 +- .../src/data-plane/server/routes/records.ts | 1 + packages/bsky/src/hydration/actor.ts | 6 + packages/bsky/src/hydration/hydrator.ts | 207 +- packages/bsky/src/hydration/label.ts | 81 +- packages/bsky/src/index.ts | 4 +- packages/bsky/src/lexicon/index.ts | 22 + packages/bsky/src/lexicon/lexicons.ts | 322 ++- .../src/lexicon/types/app/bsky/actor/defs.ts | 58 +- .../lexicon/types/app/bsky/embed/record.ts | 2 + .../lexicon/types/app/bsky/labeler/defs.ts | 93 + .../types/app/bsky/labeler/getServices.ts | 51 + .../lexicon/types/app/bsky/labeler/service.ts | 31 + .../lexicon/types/com/atproto/label/defs.ts | 68 + packages/bsky/src/proto/bsky_connect.ts | 11 + packages/bsky/src/proto/bsky_pb.ts | 146 ++ packages/bsky/src/views/index.ts | 67 +- packages/bsky/src/views/types.ts | 9 +- packages/bsky/tests/_util.ts | 21 + .../__snapshots__/indexing.test.ts.snap | 20 + packages/bsky/tests/label-hydration.test.ts | 100 + .../labeler-service.test.ts.snap | 160 ++ .../views/__snapshots__/profile.test.ts.snap | 40 + .../bsky/tests/views/labeler-service.test.ts | 156 ++ packages/dev-env/src/bsky.ts | 4 +- packages/dev-env/src/mock/index.ts | 28 +- packages/dev-env/src/pds.ts | 4 +- packages/ozone/src/lexicon/index.ts | 22 + packages/ozone/src/lexicon/lexicons.ts | 322 ++- .../src/lexicon/types/app/bsky/actor/defs.ts | 58 +- .../lexicon/types/app/bsky/embed/record.ts | 2 + .../lexicon/types/app/bsky/labeler/defs.ts | 93 + .../types/app/bsky/labeler/getServices.ts | 51 + .../lexicon/types/app/bsky/labeler/service.ts | 31 + .../lexicon/types/com/atproto/label/defs.ts | 68 + .../pds/src/api/app/bsky/actor/getProfile.ts | 4 +- .../pds/src/api/app/bsky/actor/getProfiles.ts | 4 +- .../src/api/app/bsky/actor/getSuggestions.ts | 4 +- .../src/api/app/bsky/actor/searchActors.ts | 4 +- .../app/bsky/actor/searchActorsTypeahead.ts | 4 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 4 +- .../src/api/app/bsky/feed/getActorLikes.ts | 4 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 4 +- packages/pds/src/api/app/bsky/feed/getFeed.ts | 5 +- .../src/api/app/bsky/feed/getFeedGenerator.ts | 4 +- .../api/app/bsky/feed/getFeedGenerators.ts | 4 +- .../pds/src/api/app/bsky/feed/getLikes.ts | 4 +- .../pds/src/api/app/bsky/feed/getListFeed.ts | 4 +- .../src/api/app/bsky/feed/getPostThread.ts | 4 +- .../pds/src/api/app/bsky/feed/getPosts.ts | 4 +- .../src/api/app/bsky/feed/getRepostedBy.ts | 4 +- .../api/app/bsky/feed/getSuggestedFeeds.ts | 4 +- .../pds/src/api/app/bsky/feed/getTimeline.ts | 4 +- .../pds/src/api/app/bsky/feed/searchPosts.ts | 4 +- .../pds/src/api/app/bsky/graph/getBlocks.ts | 4 +- .../src/api/app/bsky/graph/getFollowers.ts | 4 +- .../pds/src/api/app/bsky/graph/getFollows.ts | 4 +- .../pds/src/api/app/bsky/graph/getList.ts | 4 +- .../src/api/app/bsky/graph/getListBlocks.ts | 4 +- .../src/api/app/bsky/graph/getListMutes.ts | 4 +- .../pds/src/api/app/bsky/graph/getLists.ts | 4 +- .../pds/src/api/app/bsky/graph/getMutes.ts | 4 +- .../bsky/graph/getSuggestedFollowsByActor.ts | 4 +- .../pds/src/api/app/bsky/graph/muteActor.ts | 4 +- .../src/api/app/bsky/graph/muteActorList.ts | 4 +- .../pds/src/api/app/bsky/graph/unmuteActor.ts | 4 +- .../src/api/app/bsky/graph/unmuteActorList.ts | 4 +- packages/pds/src/api/app/bsky/index.ts | 2 + .../src/api/app/bsky/labeler/getServices.ts | 21 + .../pds/src/api/app/bsky/labeler/index.ts | 7 + .../app/bsky/notification/getUnreadCount.ts | 4 +- .../bsky/notification/listNotifications.ts | 4 +- .../api/app/bsky/notification/registerPush.ts | 4 +- .../api/app/bsky/notification/updateSeen.ts | 4 +- .../unspecced/getPopularFeedGenerators.ts | 4 +- .../bsky/unspecced/getTaggedSuggestions.ts | 4 +- .../src/api/com/atproto/admin/sendEmail.ts | 3 +- packages/pds/src/context.ts | 22 +- packages/pds/src/lexicon/index.ts | 22 + packages/pds/src/lexicon/lexicons.ts | 322 ++- .../src/lexicon/types/app/bsky/actor/defs.ts | 58 +- .../lexicon/types/app/bsky/embed/record.ts | 2 + .../lexicon/types/app/bsky/labeler/defs.ts | 93 + .../types/app/bsky/labeler/getServices.ts | 51 + .../lexicon/types/app/bsky/labeler/service.ts | 31 + .../lexicon/types/com/atproto/label/defs.ts | 68 + .../proxied/__snapshots__/views.test.ts.snap | 15 + 195 files changed, 8534 insertions(+), 9819 deletions(-) create mode 100644 lexicons/app/bsky/labeler/defs.json create mode 100644 lexicons/app/bsky/labeler/getServices.json create mode 100644 lexicons/app/bsky/labeler/service.json delete mode 100644 packages/api/definitions/locale/en/label-groups.json delete mode 100644 packages/api/definitions/locale/en/labels.json delete mode 100644 packages/api/definitions/locale/en/proposed-label-groups.json delete mode 100644 packages/api/definitions/locale/en/proposed-labels.json delete mode 100644 packages/api/definitions/moderation-behaviors.d.ts delete mode 100644 packages/api/definitions/post-moderation-behaviors.json delete mode 100644 packages/api/definitions/profile-moderation-behaviors.json delete mode 100644 packages/api/definitions/proposed-labels.json delete mode 100644 packages/api/docs/moderation-behaviors/posts.md delete mode 100644 packages/api/docs/moderation-behaviors/profiles.md delete mode 100644 packages/api/scripts/code/label-groups.mjs delete mode 100644 packages/api/scripts/docs/post-moderation-behaviors.mjs delete mode 100644 packages/api/scripts/docs/profile-moderation-behaviors.mjs create mode 100644 packages/api/src/client/types/app/bsky/labeler/defs.ts create mode 100644 packages/api/src/client/types/app/bsky/labeler/getServices.ts create mode 100644 packages/api/src/client/types/app/bsky/labeler/service.ts create mode 100644 packages/api/src/const.ts create mode 100644 packages/api/src/mocker.ts delete mode 100644 packages/api/src/moderation/accumulator.ts delete mode 100644 packages/api/src/moderation/const/label-groups.ts create mode 100644 packages/api/src/moderation/decision.ts create mode 100644 packages/api/src/moderation/subjects/notification.ts delete mode 100644 packages/api/src/moderation/subjects/quoted-post.ts create mode 100644 packages/api/src/moderation/ui.ts create mode 100644 packages/api/tests/moderation-behaviors.test.ts create mode 100644 packages/api/tests/moderation-custom-labels.test.ts create mode 100644 packages/api/tests/moderation-prefs.test.ts delete mode 100644 packages/api/tests/post-moderation.test.ts delete mode 100644 packages/api/tests/profile-moderation.test.ts create mode 100644 packages/api/tests/util/echo-server.ts create mode 100644 packages/bsky/src/api/app/bsky/labeler/getServices.ts create mode 100644 packages/bsky/src/data-plane/server/db/migrations/20240226T225725627Z-labelers.ts create mode 100644 packages/bsky/src/data-plane/server/db/tables/labeler.ts create mode 100644 packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/labeler/defs.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/labeler/getServices.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/labeler/service.ts create mode 100644 packages/bsky/tests/label-hydration.test.ts create mode 100644 packages/bsky/tests/views/__snapshots__/labeler-service.test.ts.snap create mode 100644 packages/bsky/tests/views/labeler-service.test.ts create mode 100644 packages/ozone/src/lexicon/types/app/bsky/labeler/defs.ts create mode 100644 packages/ozone/src/lexicon/types/app/bsky/labeler/getServices.ts create mode 100644 packages/ozone/src/lexicon/types/app/bsky/labeler/service.ts create mode 100644 packages/pds/src/api/app/bsky/labeler/getServices.ts create mode 100644 packages/pds/src/api/app/bsky/labeler/index.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/labeler/defs.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/labeler/getServices.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/labeler/service.ts diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index ee501c28251..c0dd41d31f2 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -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" + } + } } } } diff --git a/lexicons/app/bsky/embed/record.json b/lexicons/app/bsky/embed/record.json index fff9730237d..73b9488f1c5 100644 --- a/lexicons/app/bsky/embed/record.json +++ b/lexicons/app/bsky/embed/record.json @@ -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" ] } } diff --git a/lexicons/app/bsky/labeler/defs.json b/lexicons/app/bsky/labeler/defs.json new file mode 100644 index 00000000000..e122ef55f1d --- /dev/null +++ b/lexicons/app/bsky/labeler/defs.json @@ -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" + } + } + } + } + } +} diff --git a/lexicons/app/bsky/labeler/getServices.json b/lexicons/app/bsky/labeler/getServices.json new file mode 100644 index 00000000000..df6844e6183 --- /dev/null +++ b/lexicons/app/bsky/labeler/getServices.json @@ -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" + ] + } + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/labeler/service.json b/lexicons/app/bsky/labeler/service.json new file mode 100644 index 00000000000..a1ae011be56 --- /dev/null +++ b/lexicons/app/bsky/labeler/service.json @@ -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" } + } + } + } + } +} diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json index 06e4e8f9cd2..cd8e03e116c 100644 --- a/lexicons/com/atproto/label/defs.json +++ b/lexicons/com/atproto/label/defs.json @@ -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" + ] } } } diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json index acb6fa02c49..c29c44d5d1b 100644 --- a/packages/api/definitions/labels.json +++ b/packages/api/definitions/labels.json @@ -1,224 +1,226 @@ [ { - "id": "system", + "identifier": "!hide", "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": "!no-unauthenticated", - "preferences": ["hide"], - "flags": ["no-override", "unauthed"], - "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" + }, + "profile": { + "avatar": "blur", + "banner": "blur", + "displayName": "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": {} + }, + { + "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" + }, + "profile": { + "avatar": "blur", + "banner": "blur", + "displayName": "blur" + }, + "content": { + "contentList": "blur", + "contentView": "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" + "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" + }, + "profile": { + "avatar": "blur", + "banner": "blur", + "displayName": "blur" + }, + "content": { + "contentList": "blur", + "contentView": "blur" } - ] + } }, { - "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" + "identifier": "dmca-violation", + "configurable": false, + "defaultSetting": "hide", + "flags": ["no-override", "no-self"], + "severity": "none", + "blurs": "content", + "behaviors": { + "account": { + "profileList": "blur", + "profileView": "blur", + "contentList": "blur", + "contentView": "blur" + }, + "profile": { + "profileList": "blur", + "profileView": "blur" + }, + "content": { + "contentList": "blur", + "contentView": "blur" } - ] + } }, { - "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" + "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": "rude", + "identifier": "porn", "configurable": true, - "labels": [ - { - "id": "threat", - "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": "curation", + "identifier": "sexual", "configurable": true, - "labels": [ - { - "id": "spoiler", - "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": "spam", + "identifier": "nudity", "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": "gore", + "flags": ["adult"], "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": "misleading", - "preferences": ["ignore", "warn", "hide"], - "flags": [], - "onwarn": "alert" + "defaultSetting": "warn", + "severity": "none", + "blurs": "media", + "behaviors": { + "account": { + "avatar": "blur", + "banner": "blur" + }, + "profile": { + "avatar": "blur", + "banner": "blur" + }, + "content": { + "contentMedia": "blur" } - ] + } } ] diff --git a/packages/api/definitions/locale/en/label-groups.json b/packages/api/definitions/locale/en/label-groups.json deleted file mode 100644 index 06cc6699a7b..00000000000 --- a/packages/api/definitions/locale/en/label-groups.json +++ /dev/null @@ -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." - } -} diff --git a/packages/api/definitions/locale/en/labels.json b/packages/api/definitions/locale/en/labels.json deleted file mode 100644 index 65b22db746b..00000000000 --- a/packages/api/definitions/locale/en/labels.json +++ /dev/null @@ -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." - } - } -} diff --git a/packages/api/definitions/locale/en/proposed-label-groups.json b/packages/api/definitions/locale/en/proposed-label-groups.json deleted file mode 100644 index 06cc6699a7b..00000000000 --- a/packages/api/definitions/locale/en/proposed-label-groups.json +++ /dev/null @@ -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." - } -} diff --git a/packages/api/definitions/locale/en/proposed-labels.json b/packages/api/definitions/locale/en/proposed-labels.json deleted file mode 100644 index e789103dfc4..00000000000 --- a/packages/api/definitions/locale/en/proposed-labels.json +++ /dev/null @@ -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." - } - } -} diff --git a/packages/api/definitions/moderation-behaviors.d.ts b/packages/api/definitions/moderation-behaviors.d.ts deleted file mode 100644 index 2b29d93b26e..00000000000 --- a/packages/api/definitions/moderation-behaviors.d.ts +++ /dev/null @@ -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 - } - > - scenarios: Record -} diff --git a/packages/api/definitions/post-moderation-behaviors.json b/packages/api/definitions/post-moderation-behaviors.json deleted file mode 100644 index c2a268ce5f5..00000000000 --- a/packages/api/definitions/post-moderation-behaviors.json +++ /dev/null @@ -1,1222 +0,0 @@ -{ - "users": { - "self": { - "blocking": false, - "blockedBy": false, - "muted": false, - "mutedByList": false - }, - "alice": { - "blocking": false, - "blockedBy": false, - "muted": false, - "mutedByList": false - }, - "bob": { - "blocking": true, - "blockedBy": false, - "muted": false, - "mutedByList": false - }, - "carla": { - "blocking": false, - "blockedBy": true, - "muted": false, - "mutedByList": false - }, - "dan": { - "blocking": false, - "blockedBy": false, - "muted": true, - "mutedByList": false - }, - "elise": { - "blocking": false, - "blockedBy": false, - "muted": false, - "mutedByList": true - }, - "fern": { - "blocking": true, - "blockedBy": true, - "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" } - }, - "porn-hide-intolerant-hide": { - "adultContentEnabled": true, - "settings": { "porn": "hide", "intolerant": "hide" } - }, - "porn-hide-intolerant-warn": { - "adultContentEnabled": true, - "settings": { "porn": "hide", "intolerant": "warn" } - }, - "porn-warn-intolerant-hide": { - "adultContentEnabled": true, - "settings": { "porn": "warn", "intolerant": "hide" } - }, - "logged-out": { - "authed": false - } - }, - "scenarios": { - "Imperative label ('!hide') on post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "post": ["!hide"] }, - "behaviors": { - "content": { - "cause": "label:!hide", - "filter": true, - "blur": true, - "noOverride": true - } - } - }, - "Imperative label ('!hide') on author profile": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "profile": ["!hide"] }, - "behaviors": { - "avatar": { "cause": "label:!hide", "blur": true, "noOverride": true } - } - }, - "Imperative label ('!hide') on author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "account": ["!hide"] }, - "behaviors": { - "content": { - "cause": "label:!hide", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "cause": "label:!hide", "blur": true, "noOverride": true } - } - }, - "Imperative label ('!hide') on quoted post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!hide"] }, - "behaviors": { - "content": { "cause": "label:!hide", "filter": true }, - "embed": { "cause": "label:!hide", "blur": true, "noOverride": true } - } - }, - "Imperative label ('!hide') on quoted author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["!hide"] }, - "behaviors": { - "content": { "cause": "label:!hide", "filter": true }, - "embed": { "cause": "label:!hide", "blur": true, "noOverride": true } - } - }, - - "Imperative label ('!no-promote') on post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "post": ["!no-promote"] }, - "behaviors": { - "content": { "cause": "label:!no-promote", "filter": true } - } - }, - "Imperative label ('!no-promote') on author profile": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "profile": ["!no-promote"] }, - "behaviors": {} - }, - "Imperative label ('!no-promote') on author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "account": ["!no-promote"] }, - "behaviors": { - "content": { "cause": "label:!no-promote", "filter": true } - } - }, - "Imperative label ('!no-promote') on quoted post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!no-promote"] }, - "behaviors": { - "content": { "cause": "label:!no-promote", "filter": true } - } - }, - "Imperative label ('!no-promote') on quoted author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["!no-promote"] }, - "behaviors": { - "content": { "cause": "label:!no-promote", "filter": true } - } - }, - - "Imperative label ('!warn') on post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "post": ["!warn"] }, - "behaviors": { - "content": { "cause": "label:!warn", "blur": true } - } - }, - "Imperative label ('!warn') on author profile": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "profile": ["!warn"] }, - "behaviors": { - "avatar": { "blur": true } - } - }, - "Imperative label ('!warn') on author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "labels": { "account": ["!warn"] }, - "behaviors": { - "content": { "cause": "label:!warn", "blur": true }, - "avatar": { "blur": true } - } - }, - "Imperative label ('!warn') on quoted post": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!warn"] }, - "behaviors": { - "embed": { "cause": "label:!warn", "blur": true } - } - }, - "Imperative label ('!warn') on quoted author account": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["!warn"] }, - "behaviors": { - "embed": { "cause": "label:!warn", "blur": true } - } - }, - - "Imperative label ('!no-unauthenticated') on post when logged out": { - "cfg": "logged-out", - "subject": "post", - "author": "alice", - "labels": { "post": ["!no-unauthenticated"] }, - "behaviors": { - "content": { - "cause": "label:!no-unauthenticated", - "filter": true, - "blur": true, - "noOverride": true - } - } - }, - "Imperative label ('!no-unauthenticated') on author profile when logged out": { - "cfg": "logged-out", - "subject": "post", - "author": "alice", - "labels": { "profile": ["!no-unauthenticated"] }, - "behaviors": { - "content": { - "cause": "label:!no-unauthenticated", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { - "cause": "label:!no-unauthenticated", - "blur": true, - "noOverride": true - } - } - }, - "Imperative label ('!no-unauthenticated') on author account when logged out": { - "cfg": "logged-out", - "subject": "post", - "author": "alice", - "labels": { "account": ["!no-unauthenticated"] }, - "behaviors": { - "content": { - "cause": "label:!no-unauthenticated", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { - "cause": "label:!no-unauthenticated", - "blur": true, - "noOverride": true - } - } - }, - "Imperative label ('!no-unauthenticated') on quoted post when logged out": { - "cfg": "logged-out", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!no-unauthenticated"] }, - "behaviors": { - "content": { "cause": "label:!no-unauthenticated", "filter": true }, - "embed": { - "cause": "label:!no-unauthenticated", - "blur": true, - "noOverride": true - } - } - }, - "Imperative label ('!no-unauthenticated') on quoted author account when logged out": { - "cfg": "logged-out", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["!no-unauthenticated"] }, - "behaviors": { - "content": { "cause": "label:!no-unauthenticated", "filter": true }, - "embed": { - "cause": "label:!no-unauthenticated", - "blur": true, - "noOverride": true - } - } - }, - - "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": {} - }, - "Imperative label ('!no-unauthenticated') on quoted post when logged in": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!no-unauthenticated"] }, - "behaviors": {} - }, - "Imperative label ('!no-unauthenticated') on quoted author account when logged in": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["!no-unauthenticated"] }, - "behaviors": {} - }, - - "Blur label ('intolerant') on post (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "filter": true, "blur": true } - } - }, - "Blur label ('intolerant') on author profile (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "labels": { "profile": ["intolerant"] }, - "behaviors": { - "avatar": { "blur": true } - } - }, - "Blur label ('intolerant') on author account (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "labels": { "account": ["intolerant"] }, - "behaviors": { - "content": { - "cause": "label:intolerant", - "filter": true, - "blur": true - }, - "avatar": { "blur": true } - } - }, - "Blur label ('intolerant') on quoted post (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "filter": true }, - "embed": { "cause": "label:intolerant", "blur": true } - } - }, - "Blur label ('intolerant') on quoted author account (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "filter": true }, - "embed": { "cause": "label:intolerant", "blur": true } - } - }, - - "Blur label ('intolerant') on post (warn)": { - "cfg": "intolerant-warn", - "subject": "post", - "author": "alice", - "labels": { "post": ["intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "blur": true } - } - }, - "Blur label ('intolerant') on author profile (warn)": { - "cfg": "intolerant-warn", - "subject": "post", - "author": "alice", - "labels": { "profile": ["intolerant"] }, - "behaviors": { - "avatar": { "blur": true } - } - }, - "Blur label ('intolerant') on author account (warn)": { - "cfg": "intolerant-warn", - "subject": "post", - "author": "alice", - "labels": { "account": ["intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "blur": true }, - "avatar": { "blur": true } - } - }, - "Blur label ('intolerant') on quoted post (warn)": { - "cfg": "intolerant-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["intolerant"] }, - "behaviors": { - "embed": { "cause": "label:intolerant", "blur": true } - } - }, - "Blur label ('intolerant') on quoted author account (warn)": { - "cfg": "intolerant-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["intolerant"] }, - "behaviors": { - "embed": { "cause": "label:intolerant", "blur": true } - } - }, - - "Blur label ('intolerant') on post (ignore)": { - "cfg": "intolerant-ignore", - "subject": "post", - "author": "alice", - "labels": { "post": ["intolerant"] }, - "behaviors": {} - }, - "Blur label ('intolerant') on author profile (ignore)": { - "cfg": "intolerant-ignore", - "subject": "post", - "author": "alice", - "labels": { "profile": ["intolerant"] }, - "behaviors": {} - }, - "Blur label ('intolerant') on author account (ignore)": { - "cfg": "intolerant-ignore", - "subject": "post", - "author": "alice", - "labels": { "account": ["intolerant"] }, - "behaviors": {} - }, - "Blur label ('intolerant') on quoted post (ignore)": { - "cfg": "intolerant-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["intolerant"] }, - "behaviors": {} - }, - "Blur label ('intolerant') on quoted author account (ignore)": { - "cfg": "intolerant-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["intolerant"] }, - "behaviors": {} - }, - - "Blur-media label ('porn') on post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true }, - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on author profile (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "labels": { "profile": ["porn"] }, - "behaviors": { - "avatar": { "blur": true } - } - }, - "Blur-media label ('porn') on author account (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "labels": { "account": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true }, - "avatar": { "blur": true }, - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on quoted post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true }, - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on quoted author account (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true } - } - }, - - "Blur-media label ('porn') on post (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "alice", - "labels": { "post": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on author profile (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "alice", - "labels": { "profile": ["porn"] }, - "behaviors": { - "avatar": { "blur": true } - } - }, - "Blur-media label ('porn') on author account (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "alice", - "labels": { "account": ["porn"] }, - "behaviors": { - "avatar": { "blur": true }, - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on quoted post (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Blur-media label ('porn') on quoted author account (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["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": {} - }, - "Blur-media label ('porn') on quoted post (ignore)": { - "cfg": "porn-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["porn"] }, - "behaviors": {} - }, - "Blur-media label ('porn') on quoted author account (ignore)": { - "cfg": "porn-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["porn"] }, - "behaviors": {} - }, - - "Notice label ('scam') on post (hide)": { - "cfg": "scam-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "filter": true, "alert": true } - } - }, - "Notice label ('scam') on author profile (hide)": { - "cfg": "scam-hide", - "subject": "post", - "author": "alice", - "labels": { "profile": ["scam"] }, - "behaviors": { - "avatar": { "alert": true } - } - }, - "Notice label ('scam') on author account (hide)": { - "cfg": "scam-hide", - "subject": "post", - "author": "alice", - "labels": { "account": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "filter": true, "alert": true }, - "avatar": { "alert": true } - } - }, - "Notice label ('scam') on quoted post (hide)": { - "cfg": "scam-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "filter": true }, - "embed": { "cause": "label:scam", "alert": true } - } - }, - "Notice label ('scam') on quoted author account (hide)": { - "cfg": "scam-hide", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "filter": true }, - "embed": { "cause": "label:scam", "alert": true } - } - }, - - "Notice label ('scam') on post (warn)": { - "cfg": "scam-warn", - "subject": "post", - "author": "alice", - "labels": { "post": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "alert": true } - } - }, - "Notice label ('scam') on author profile (warn)": { - "cfg": "scam-warn", - "subject": "post", - "author": "alice", - "labels": { "profile": ["scam"] }, - "behaviors": { - "avatar": { "alert": true } - } - }, - "Notice label ('scam') on author account (warn)": { - "cfg": "scam-warn", - "subject": "post", - "author": "alice", - "labels": { "account": ["scam"] }, - "behaviors": { - "content": { "cause": "label:scam", "alert": true }, - "avatar": { "alert": true } - } - }, - "Notice label ('scam') on quoted post (warn)": { - "cfg": "scam-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["scam"] }, - "behaviors": { - "embed": { "cause": "label:scam", "alert": true } - } - }, - "Notice label ('scam') on quoted author account (warn)": { - "cfg": "scam-warn", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["scam"] }, - "behaviors": { - "embed": { "cause": "label:scam", "alert": true } - } - }, - - "Notice label ('scam') on post (ignore)": { - "cfg": "scam-ignore", - "subject": "post", - "author": "alice", - "labels": { "post": ["scam"] }, - "behaviors": {} - }, - "Notice label ('scam') on author profile (ignore)": { - "cfg": "scam-ignore", - "subject": "post", - "author": "alice", - "labels": { "profile": ["scam"] }, - "behaviors": {} - }, - "Notice label ('scam') on author account (ignore)": { - "cfg": "scam-ignore", - "subject": "post", - "author": "alice", - "labels": { "account": ["scam"] }, - "behaviors": {} - }, - "Notice label ('scam') on quoted post (ignore)": { - "cfg": "scam-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["scam"] }, - "behaviors": {} - }, - "Notice label ('scam') on quoted author account (ignore)": { - "cfg": "scam-ignore", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["scam"] }, - "behaviors": {} - }, - - "Adult-only label on post when adult content is disabled": { - "cfg": "adult-disabled", - "subject": "post", - "author": "alice", - "labels": { "post": ["porn"] }, - "behaviors": { - "content": { - "cause": "label:porn", - "filter": true, - "noOverride": true - }, - "embed": { "cause": "label:porn", "blur": true, "noOverride": true } - } - }, - "Adult-only label on author profile when adult content is disabled": { - "cfg": "adult-disabled", - "subject": "post", - "author": "alice", - "labels": { "profile": ["porn"] }, - "behaviors": { - "avatar": { "cause": "label:porn", "blur": true, "noOverride": true } - } - }, - "Adult-only label on author account when adult content is disabled": { - "cfg": "adult-disabled", - "subject": "post", - "author": "alice", - "labels": { "account": ["porn"] }, - "behaviors": { - "content": { - "cause": "label:porn", - "filter": true, - "noOverride": true - }, - "avatar": { "cause": "label:porn", "blur": true, "noOverride": true }, - "embed": { "cause": "label:porn", "blur": true, "noOverride": true } - } - }, - "Adult-only label on quoted post when adult content is disabled": { - "cfg": "adult-disabled", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true }, - "embed": { "cause": "label:porn", "blur": true, "noOverride": true } - } - }, - "Adult-only label on quoted author account when adult content is disabled": { - "cfg": "adult-disabled", - "subject": "post", - "author": "alice", - "quoteAuthor": "alice", - "labels": { "quotedAccount": ["porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true } - } - }, - - "Self-post: Imperative label ('!hide') on post": { - "cfg": "none", - "subject": "post", - "author": "self", - "labels": { "post": ["!hide"] }, - "behaviors": { - "content": { "cause": "label:!hide", "blur": true } - } - }, - "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 ('!hide') on quoted post": { - "cfg": "none", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedPost": ["!hide"] }, - "behaviors": { - "embed": { "cause": "label:!hide", "blur": true } - } - }, - "Self-post: Imperative label ('!hide') on quoted author account": { - "cfg": "none", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedAccount": ["!hide"] }, - "behaviors": {} - }, - - "Self-post: Imperative label ('!warn') on post": { - "cfg": "none", - "subject": "post", - "author": "self", - "labels": { "post": ["!warn"] }, - "behaviors": { - "content": { "cause": "label:!warn", "blur": true } - } - }, - "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": {} - }, - "Self-post: Imperative label ('!warn') on quoted post": { - "cfg": "none", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedPost": ["!warn"] }, - "behaviors": { - "embed": { "cause": "label:!warn", "blur": true } - } - }, - "Self-post: Imperative label ('!warn') on quoted author account": { - "cfg": "none", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedAccount": ["!warn"] }, - "behaviors": {} - }, - - "Self-post: Blur-media label ('porn') on post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "self", - "labels": { "post": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Self-post: Blur-media label ('porn') on author profile (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "self", - "labels": { "profile": ["porn"] }, - "behaviors": {} - }, - "Self-post: Blur-media label ('porn') on author account (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "self", - "labels": { "account": ["porn"] }, - "behaviors": {} - }, - "Self-post: Blur-media label ('porn') on quoted post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedPost": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Self-post: Blur-media label ('porn') on quoted author account (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedAccount": ["porn"] }, - "behaviors": {} - }, - - "Self-post: Blur-media label ('porn') on post (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "self", - "labels": { "post": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Self-post: Blur-media label ('porn') on author profile (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "self", - "labels": { "profile": ["porn"] }, - "behaviors": {} - }, - "Self-post: Blur-media label ('porn') on author account (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "self", - "labels": { "account": ["porn"] }, - "behaviors": {} - }, - "Self-post: Blur-media label ('porn') on quoted post (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedPost": ["porn"] }, - "behaviors": { - "embed": { "cause": "label:porn", "blur": true } - } - }, - "Self-post: Blur-media label ('porn') on quoted author account (warn)": { - "cfg": "porn-warn", - "subject": "post", - "author": "self", - "quoteAuthor": "self", - "labels": { "quotedAccount": ["porn"] }, - "behaviors": {} - }, - - "Post with blocked author": { - "cfg": "none", - "subject": "post", - "author": "bob", - "labels": {}, - "behaviors": { - "content": { - "cause": "blocking", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "blur": true, "noOverride": true } - } - }, - "Post with blocked quoted author": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "bob", - "labels": {}, - "behaviors": { - "content": { "cause": "blocking", "filter": true }, - "embed": { "cause": "blocking", "blur": true, "noOverride": true } - } - }, - - "Post with author blocking user": { - "cfg": "none", - "subject": "post", - "author": "carla", - "labels": {}, - "behaviors": { - "content": { - "cause": "blocked-by", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "blur": true, "noOverride": true } - } - }, - "Post with quoted author blocking user": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "carla", - "labels": {}, - "behaviors": { - "content": { "cause": "blocked-by", "filter": true }, - "embed": { "cause": "blocked-by", "blur": true, "noOverride": true } - } - }, - - "Post with muted author": { - "cfg": "none", - "subject": "post", - "author": "dan", - "labels": {}, - "behaviors": { - "content": { "cause": "muted", "filter": true, "blur": true } - } - }, - "Post with muted quoted author": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "dan", - "labels": {}, - "behaviors": { - "content": { "cause": "muted", "filter": true }, - "embed": { "cause": "muted", "blur": true } - } - }, - - "Post with muted-by-list author": { - "cfg": "none", - "subject": "post", - "author": "elise", - "labels": {}, - "behaviors": { - "content": { "cause": "muted-by-list", "filter": true, "blur": true } - } - }, - "Post with muted-by-list quoted author": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "elise", - "labels": {}, - "behaviors": { - "content": { "cause": "muted-by-list", "filter": true }, - "embed": { "cause": "muted-by-list", "blur": true } - } - }, - - "Prioritization: post with blocking & blocked-by author": { - "cfg": "none", - "subject": "post", - "author": "fern", - "labels": {}, - "behaviors": { - "content": { - "cause": "blocking", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "blur": true, "noOverride": true } - } - }, - "Prioritization: post with blocking & blocked-by quoted author": { - "cfg": "none", - "subject": "post", - "author": "alice", - "quoteAuthor": "fern", - "labels": {}, - "behaviors": { - "content": { "cause": "blocking", "filter": true }, - "embed": { "cause": "blocking", "blur": true, "noOverride": true } - } - }, - "Prioritization: '!hide' label on post by blocked user": { - "cfg": "none", - "subject": "post", - "author": "bob", - "labels": { "post": ["!hide"] }, - "behaviors": { - "content": { - "cause": "label:!hide", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "blur": true, "noOverride": true } - } - }, - "Prioritization: '!hide' label on quoted post, post by blocked user": { - "cfg": "none", - "subject": "post", - "author": "bob", - "quoteAuthor": "alice", - "labels": { "quotedPost": ["!hide"] }, - "behaviors": { - "content": { - "cause": "blocking", - "filter": true, - "blur": true, - "noOverride": true - }, - "avatar": { "blur": true, "noOverride": true }, - "embed": { "cause": "label:!hide", "blur": true, "noOverride": true } - } - }, - "Prioritization: '!hide' and 'intolerant' labels on post (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["!hide", "intolerant"] }, - "behaviors": { - "content": { - "cause": "label:!hide", - "filter": true, - "blur": true, - "noOverride": true - } - } - }, - "Prioritization: '!warn' and 'intolerant' labels on post (hide)": { - "cfg": "intolerant-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["!warn", "intolerant"] }, - "behaviors": { - "content": { "cause": "label:intolerant", "filter": true, "blur": true } - } - }, - "Prioritization: '!hide' and 'porn' labels on post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["!hide", "porn"] }, - "behaviors": { - "content": { - "cause": "label:!hide", - "filter": true, - "blur": true, - "noOverride": true - } - } - }, - "Prioritization: '!warn' and 'porn' labels on post (hide)": { - "cfg": "porn-hide", - "subject": "post", - "author": "alice", - "labels": { "post": ["!warn", "porn"] }, - "behaviors": { - "content": { "cause": "label:porn", "filter": true }, - "embed": { "cause": "label:porn", "blur": true } - } - } - } -} diff --git a/packages/api/definitions/profile-moderation-behaviors.json b/packages/api/definitions/profile-moderation-behaviors.json deleted file mode 100644 index 2d1e9bc8da9..00000000000 --- a/packages/api/definitions/profile-moderation-behaviors.json +++ /dev/null @@ -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 } - } - } - } -} diff --git a/packages/api/definitions/proposed-labels.json b/packages/api/definitions/proposed-labels.json deleted file mode 100644 index ad9b8924c8a..00000000000 --- a/packages/api/definitions/proposed-labels.json +++ /dev/null @@ -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" - } - ] - } -] diff --git a/packages/api/docs/labels.md b/packages/api/docs/labels.md index 943d3f54613..6d40b4f58ae 100644 --- a/packages/api/docs/labels.md +++ b/packages/api/docs/labels.md @@ -16,11 +16,9 @@ The possible client interpretations for a label. - warn Provide some form of warning on the content (see "On Warn" behavior). - hide Remove the content from feeds and apply the warning when directly viewed. -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. - - - - - - - + + + - - - - - + + + - - - - - + + + - - - + - + - - - - - + + + - - - - - + + + - - - + - - - + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDGroupPreferences Configurable Flags On Warn
!hidesystemhideno-overrideblur❌ (undefined)no-override, no-selfundefined
!no-promotesystemhidenull❌ (undefined)no-selfundefined
!warnsystemwarnblur❌ (undefined)no-selfundefined
!no-unauthenticatedsystemhide❌ (undefined) no-override, unauthedblurundefined
dmca-violationlegalhideno-overrideblur❌ (undefined)no-override, no-selfundefined
doxxinglegalhideno-overrideblur❌ (undefined)no-override, no-selfundefined
pornsexualignore, warn, hide adultblur-mediaundefined
sexualsexualignore, warn, hide adultblur-mediaundefined
nuditysexualignore, warn, hide adultblur-media
nsflviolenceignore, warn, hideadultblur-media
corpseviolenceignore, warn, hideadultblur-mediaundefined
goreviolenceignore, warn, hideadultblur-media
tortureviolenceignore, warn, hide adultblur
self-harmviolenceignore, warn, hideadultblur-media
intolerant-raceintoleranceignore, warn, hideblur
intolerant-genderintoleranceignore, warn, hideblur
intolerant-sexual-orientationintoleranceignore, warn, hideblur
intolerant-religionintoleranceignore, warn, hideblur
intolerantintoleranceignore, warn, hideblur
icon-intolerantintoleranceignore, warn, hideblur-media
threatrudeignore, warn, hideblur
spoilercurationignore, warn, hideblur
spamspamignore, warn, hideblur
account-securitymisinfoignore, warn, hideblur
net-abusemisinfoignore, warn, hideblur
impersonationmisinfoignore, warn, hidealert
scammisinfoignore, warn, hidealert
misleadingmisinfoignore, warn, hidealert
- -## Label Group Descriptions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDDescription
systemgeneral
System
Moderator overrides for special cases.
legalgeneral
Legal
Content removed for legal reasons.
sexualgeneral
Adult Content
Content which is sexual in nature.
violencegeneral
Violence
Content which is violent or deeply disturbing.
intolerancegeneral
Intolerance
Content or behavior which is hateful or intolerant toward a group of people.
rudegeneral
Rude
Behavior which is rude toward other users.
curationgeneral
Curational
Subjective moderation geared towards curating a more positive environment.
spamgeneral
Spam
Content which doesn't add to the conversation.
misinfogeneral
Misinformation
Content which misleads or defrauds users.
- -## Label Descriptions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
IDDescription
!hide - general
Moderator Hide
Moderator has chosen to hide the content.

- on an account
Content Blocked
This account has been hidden by the moderators.

- on content
Content Blocked
This content has been hidden by the moderators.

-
!no-promote - general
Moderator Filter
Moderator has chosen to filter the content from feeds.

- on an account
N/A
N/A

- on content
N/A
N/A

-
!warn - general
Moderator Warn
Moderator has chosen to set a general warning on the content.

- on an account
Content Warning
This account has received a general warning from moderators.

- on content
Content Warning
This content has received a general warning from moderators.

-
!no-unauthenticated - general
Sign-in Required
This user has requested that their account only be shown to signed-in users.

- on an account
Sign-in Required
This user has requested that their account only be shown to signed-in users.

- on content
Sign-in Required
This user has requested that their content only be shown to signed-in users.

-
dmca-violation - general
Copyright Violation
The content has received a DMCA takedown request.

- on an account
Copyright Violation
This account has received a DMCA takedown request. It will be restored if the concerns can be resolved.

- on content
Copyright Violation
This content has received a DMCA takedown request. It will be restored if the concerns can be resolved.

-
doxxing - general
Doxxing
Information that reveals private information about someone which has been shared without the consent of the subject.

- on an account
Doxxing
This account has been reported to publish private information about someone without their consent. This report is currently under review.

- on content
Doxxing
This content has been reported to include private information about someone without their consent.

-
porn - general
Pornography
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).

- on an account
Adult Content
This account contains imagery of full-frontal nudity or explicit sexual activity.

- on content
Adult Content
This content contains imagery of full-frontal nudity or explicit sexual activity.

-
sexual - general
Sexually Suggestive
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.

- on an account
Suggestive Content
This account contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.

- on content
Suggestive Content
This content contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.

-
nudity - general
Nudity
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.

- on an account
Adult Content
This account contains imagery which portrays nudity in a non-sexual or artistic setting.

- on content
Adult Content
This content contains imagery which portrays nudity in a non-sexual or artistic setting.

-
nsfl - general
NSFL
"Not Suitable For Life." This includes graphic images like the infamous "goatse" (don't look it up).

- on an account
Graphic Imagery (NSFL)
This account contains graphic images which are often referred to as "Not Suitable For Life."

- on content
Graphic Imagery (NSFL)
This content contains graphic images which are often referred to as "Not Suitable For Life."

-
corpse - general
Corpse
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.

- on an account
Graphic Imagery (Corpse)
This account contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.

- on content
Graphic Imagery (Corpse)
This content contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.

-
gore - general
Gore
Intended for shocking images, typically involving blood or visible wounds.

- on an account
Graphic Imagery (Gore)
This account contains shocking images involving blood or visible wounds.

- on content
Graphic Imagery (Gore)
This content contains shocking images involving blood or visible wounds.

-
torture - general
Torture
Depictions of torture of a human or animal (animal cruelty).

- on an account
Graphic Imagery (Torture)
This account contains depictions of torture of a human or animal.

- on content
Graphic Imagery (Torture)
This content contains depictions of torture of a human or animal.

-
self-harm - general
Self-Harm
A visual depiction (photo or figurative) of cutting, suicide, or similar.

- on an account
Graphic Imagery (Self-Harm)
This account includes depictions of cutting, suicide, or other forms of self-harm.

- on content
Graphic Imagery (Self-Harm)
This content includes depictions of cutting, suicide, or other forms of self-harm.

-
intolerant-race - general
Racial Intolerance
Hateful or intolerant content related to race.

- on an account
Intolerance (Racial)
This account includes hateful or intolerant content related to race.

- on content
Intolerance (Racial)
This content includes hateful or intolerant views related to race.

-
intolerant-gender - general
Gender Intolerance
Hateful or intolerant content related to gender or gender identity.

- on an account
Intolerance (Gender)
This account includes hateful or intolerant content related to gender or gender identity.

- on content
Intolerance (Gender)
This content includes hateful or intolerant views related to gender or gender identity.

-
intolerant-sexual-orientation - general
Sexual Orientation Intolerance
Hateful or intolerant content related to sexual preferences.

- on an account
Intolerance (Orientation)
This account includes hateful or intolerant content related to sexual preferences.

- on content
Intolerance (Orientation)
This content includes hateful or intolerant views related to sexual preferences.

-
intolerant-religion - general
Religious Intolerance
Hateful or intolerant content related to religious views or practices.

- on an account
Intolerance (Religious)
This account includes hateful or intolerant content related to religious views or practices.

- on content
Intolerance (Religious)
This content includes hateful or intolerant views related to religious views or practices.

-
intolerant - general
Intolerance
A catchall for hateful or intolerant content which is not covered elsewhere.

- on an account
Intolerance
This account includes hateful or intolerant content.

- on content
Intolerance
This content includes hateful or intolerant views.

-
icon-intolerant - general
Intolerant Iconography
Visual imagery associated with a hate group, such as the KKK or Nazi, in any context (supportive, critical, documentary, etc).

- on an account
Intolerant Iconography
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.

- on content
Intolerant Iconography
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 - general
Threats
Statements or imagery published with the intent to threaten, intimidate, or harm.

- on an account
Threats
The moderators believe this account has published statements or imagery with the intent to threaten, intimidate, or harm others.

- on content
Threats
The moderators believe this content was published with the intent to threaten, intimidate, or harm others.

-
spoiler - general
Spoiler
Discussion about film, TV, etc which gives away plot points.

- on an account
Spoiler Warning
This account contains discussion about film, TV, etc which gives away plot points.

- on content
Spoiler Warning
This content contains discussion about film, TV, etc which gives away plot points.

-
spam - general
Spam
Repeat, low-quality messages which are clearly not designed to add to a conversation or space.

- on an account
Spam
This account publishes repeat, low-quality messages which are clearly not designed to add to a conversation or space.

- on content
Spam
This content is a part of repeat, low-quality messages which are clearly not designed to add to a conversation or space.

-
account-security - general
Security Concerns
Content designed to hijack user accounts such as a phishing attack.

- on an account
Security Warning
This account has published content designed to hijack user accounts such as a phishing attack.

- on content
Security Warning
This content is designed to hijack user accounts such as a phishing attack.

-
net-abuse - general
Network Attacks
Content designed to attack network systems such as denial-of-service attacks.

- on an account
Network Attack Warning
This account has published content designed to attack network systems such as denial-of-service attacks.

- on content
Network Attack Warning
This content is designed to attack network systems such as denial-of-service attacks.

-
impersonation - general
Impersonation
Accounts which falsely assert some identity.

- on an account
Impersonation Warning
The moderators believe this account is lying about their identity.

- on content
Impersonation Warning
The moderators believe this account is lying about their identity.

-
scam - general
Scam
Fraudulent content.

- on an account
Scam Warning
The moderators believe this account publishes fraudulent content.

- on content
Scam Warning
The moderators believe this is fraudulent content.

-
misleading - general
Misleading
Accounts which share misleading information.

- on an account
Misleading
The moderators believe this account is spreading misleading information.

- on content
Misleading
The moderators believe this account is spreading misleading information.

-
undefined
diff --git a/packages/api/docs/moderation-behaviors/posts.md b/packages/api/docs/moderation-behaviors/posts.md deleted file mode 100644 index a76ff8b19df..00000000000 --- a/packages/api/docs/moderation-behaviors/posts.md +++ /dev/null @@ -1,1813 +0,0 @@ - - -# Post moderation behaviors - -This document is a reference for the expected behaviors for a post in the application based on some given scenarios. The moderatePost() command condense down to the following yes or no decisions: - -- res.content.filter Do not show the post in feeds. -- res.content.blur Put the post behind a warning cover. -- res.content.noOverride Do not allow the post's blur cover to be lifted. -- res.content.alert Add a warning to the post but do not cover it. -- res.avatar.blur Put the avatar behind a cover. -- res.avatar.noOverride Do not allow the avatars's blur cover to be lifted. -- res.avatar.alert Put a warning icon on the avatar. -- res.embed.blur Put the embed content (media, quote post) behind a warning cover. -- res.embed.noOverride Do not allow the embed's blur cover to be lifted. -- res.embed.alert Put a warning on the embed content (media, quote post). - -Key: - -- ❌ = Filter Content -- 🚫 = Blur (no-override) -- ✋ = Blur -- 🪧 = Alert - -## Scenarios - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ScenarioFilterContentAvatarEmbed
Imperative label ('!hide') on post -❌ - -🚫 - - - - -
Imperative label ('!hide') on author profile - - - - -🚫 - - -
Imperative label ('!hide') on author account -❌ - -🚫 - -🚫 - - -
Imperative label ('!hide') on quoted post -❌ - - - - - -🚫 -
Imperative label ('!hide') on quoted author account -❌ - - - - - -🚫 -
Imperative label ('!no-promote') on post -❌ - - - - - - -
Imperative label ('!no-promote') on author profile - - - - - - - -
Imperative label ('!no-promote') on author account -❌ - - - - - - -
Imperative label ('!no-promote') on quoted post -❌ - - - - - - -
Imperative label ('!no-promote') on quoted author account -❌ - - - - - - -
Imperative label ('!warn') on post - - -✋ - - - - -
Imperative label ('!warn') on author profile - - - - -✋ - - -
Imperative label ('!warn') on author account - - -✋ - -✋ - - -
Imperative label ('!warn') on quoted post - - - - - - -✋ -
Imperative label ('!warn') on quoted author account - - - - - - -✋ -
Imperative label ('!no-unauthenticated') on post when logged out -❌ - -🚫 - - - - -
Imperative label ('!no-unauthenticated') on author profile when logged out -❌ - -🚫 - -🚫 - - -
Imperative label ('!no-unauthenticated') on author account when logged out -❌ - -🚫 - -🚫 - - -
Imperative label ('!no-unauthenticated') on quoted post when logged out -❌ - - - - - -🚫 -
Imperative label ('!no-unauthenticated') on quoted author account when logged out -❌ - - - - - -🚫 -
Imperative label ('!no-unauthenticated') on post when logged in - - - - - - - -
Imperative label ('!no-unauthenticated') on author profile when logged in - - - - - - - -
Imperative label ('!no-unauthenticated') on author account when logged in - - - - - - - -
Imperative label ('!no-unauthenticated') on quoted post when logged in - - - - - - - -
Imperative label ('!no-unauthenticated') on quoted author account when logged in - - - - - - - -
ScenarioFilterContentAvatarEmbed
Blur label ('intolerant') on post (hide) -❌ - -✋ - - - - -
Blur label ('intolerant') on author profile (hide) - - - - -✋ - - -
Blur label ('intolerant') on author account (hide) -❌ - -✋ - -✋ - - -
Blur label ('intolerant') on quoted post (hide) -❌ - - - - - -✋ -
Blur label ('intolerant') on quoted author account (hide) -❌ - - - - - -✋ -
Blur label ('intolerant') on post (warn) - - -✋ - - - - -
Blur label ('intolerant') on author profile (warn) - - - - -✋ - - -
Blur label ('intolerant') on author account (warn) - - -✋ - -✋ - - -
Blur label ('intolerant') on quoted post (warn) - - - - - - -✋ -
Blur label ('intolerant') on quoted author account (warn) - - - - - - -✋ -
Blur label ('intolerant') on post (ignore) - - - - - - - -
Blur label ('intolerant') on author profile (ignore) - - - - - - - -
Blur label ('intolerant') on author account (ignore) - - - - - - - -
Blur label ('intolerant') on quoted post (ignore) - - - - - - - -
Blur label ('intolerant') on quoted author account (ignore) - - - - - - - -
ScenarioFilterContentAvatarEmbed
Blur-media label ('porn') on post (hide) -❌ - - - - - -✋ -
Blur-media label ('porn') on author profile (hide) - - - - -✋ - - -
Blur-media label ('porn') on author account (hide) -❌ - - - -✋ - -✋ -
Blur-media label ('porn') on quoted post (hide) -❌ - - - - - -✋ -
Blur-media label ('porn') on quoted author account (hide) -❌ - - - - - - -
Blur-media label ('porn') on post (warn) - - - - - - -✋ -
Blur-media label ('porn') on author profile (warn) - - - - -✋ - - -
Blur-media label ('porn') on author account (warn) - - - - -✋ - -✋ -
Blur-media label ('porn') on quoted post (warn) - - - - - - -✋ -
Blur-media label ('porn') on quoted author account (warn) - - - - - - - -
Blur-media label ('porn') on post (ignore) - - - - - - - -
Blur-media label ('porn') on author profile (ignore) - - - - - - - -
Blur-media label ('porn') on author account (ignore) - - - - - - - -
Blur-media label ('porn') on quoted post (ignore) - - - - - - - -
Blur-media label ('porn') on quoted author account (ignore) - - - - - - - -
ScenarioFilterContentAvatarEmbed
Notice label ('scam') on post (hide) -❌ - -🪧 - - - - -
Notice label ('scam') on author profile (hide) - - - - -🪧 - - -
Notice label ('scam') on author account (hide) -❌ - -🪧 - -🪧 - - -
Notice label ('scam') on quoted post (hide) -❌ - - - - - -🪧 -
Notice label ('scam') on quoted author account (hide) -❌ - - - - - -🪧 -
Notice label ('scam') on post (warn) - - -🪧 - - - - -
Notice label ('scam') on author profile (warn) - - - - -🪧 - - -
Notice label ('scam') on author account (warn) - - -🪧 - -🪧 - - -
Notice label ('scam') on quoted post (warn) - - - - - - -🪧 -
Notice label ('scam') on quoted author account (warn) - - - - - - -🪧 -
Notice label ('scam') on post (ignore) - - - - - - - -
Notice label ('scam') on author profile (ignore) - - - - - - - -
Notice label ('scam') on author account (ignore) - - - - - - - -
Notice label ('scam') on quoted post (ignore) - - - - - - - -
Notice label ('scam') on quoted author account (ignore) - - - - - - - -
ScenarioFilterContentAvatarEmbed
Adult-only label on post when adult content is disabled -❌ - - - - - -🚫 -
Adult-only label on author profile when adult content is disabled - - - - -🚫 - - -
Adult-only label on author account when adult content is disabled -❌ - - - -🚫 - -🚫 -
Adult-only label on quoted post when adult content is disabled -❌ - - - - - -🚫 -
Adult-only label on quoted author account when adult content is disabled -❌ - - - - - - -
ScenarioFilterContentAvatarEmbed
Self-post: Imperative label ('!hide') on post - - -✋ - - - - -
Self-post: Imperative label ('!hide') on author profile - - - - - - - -
Self-post: Imperative label ('!hide') on author account - - - - - - - -
Self-post: Imperative label ('!hide') on quoted post - - - - - - -✋ -
Self-post: Imperative label ('!hide') on quoted author account - - - - - - - -
Self-post: Imperative label ('!warn') on post - - -✋ - - - - -
Self-post: Imperative label ('!warn') on author profile - - - - - - - -
Self-post: Imperative label ('!warn') on author account - - - - - - - -
Self-post: Imperative label ('!warn') on quoted post - - - - - - -✋ -
Self-post: Imperative label ('!warn') on quoted author account - - - - - - - -
Self-post: Blur-media label ('porn') on post (hide) - - - - - - -✋ -
Self-post: Blur-media label ('porn') on author profile (hide) - - - - - - - -
Self-post: Blur-media label ('porn') on author account (hide) - - - - - - - -
Self-post: Blur-media label ('porn') on quoted post (hide) - - - - - - -✋ -
Self-post: Blur-media label ('porn') on quoted author account (hide) - - - - - - - -
Self-post: Blur-media label ('porn') on post (warn) - - - - - - -✋ -
Self-post: Blur-media label ('porn') on author profile (warn) - - - - - - - -
Self-post: Blur-media label ('porn') on author account (warn) - - - - - - - -
Self-post: Blur-media label ('porn') on quoted post (warn) - - - - - - -✋ -
Self-post: Blur-media label ('porn') on quoted author account (warn) - - - - - - - -
ScenarioFilterContentAvatarEmbed
Post with blocked author -❌ - -🚫 - -🚫 - - -
Post with blocked quoted author -❌ - - - - - -🚫 -
Post with author blocking user -❌ - -🚫 - -🚫 - - -
Post with quoted author blocking user -❌ - - - - - -🚫 -
Post with muted author -❌ - -✋ - - - - -
Post with muted quoted author -❌ - - - - - -✋ -
Post with muted-by-list author -❌ - -✋ - - - - -
Post with muted-by-list quoted author -❌ - - - - - -✋ -
ScenarioFilterContentAvatarEmbed
Prioritization: post with blocking & blocked-by author -❌ - -🚫 - -🚫 - - -
Prioritization: post with blocking & blocked-by quoted author -❌ - - - - - -🚫 -
Prioritization: '!hide' label on post by blocked user -❌ - -🚫 - -🚫 - - -
Prioritization: '!hide' label on quoted post, post by blocked user -❌ - -🚫 - -🚫 - -🚫 -
Prioritization: '!hide' and 'intolerant' labels on post (hide) -❌ - -🚫 - - - - -
Prioritization: '!warn' and 'intolerant' labels on post (hide) -❌ - -✋ - - - - -
Prioritization: '!hide' and 'porn' labels on post (hide) -❌ - -🚫 - - - - -
Prioritization: '!warn' and 'porn' labels on post (hide) -❌ - - - - - -✋ -
diff --git a/packages/api/docs/moderation-behaviors/profiles.md b/packages/api/docs/moderation-behaviors/profiles.md deleted file mode 100644 index f3c45e8c556..00000000000 --- a/packages/api/docs/moderation-behaviors/profiles.md +++ /dev/null @@ -1,833 +0,0 @@ - - -# Profile moderation behaviors - -This document is a reference for the expected behaviors for a profile in the application based on some given scenarios. The moderateProfile() command condense down to the following yes or no decisions: - -- res.account.filter Do not show the account in feeds. -- res.account.blur Put the account (in listings, when viewing) behind a warning cover. -- res.account.noOverride Do not allow the account's blur cover to be lifted. -- res.account.alert Add a warning to the account but do not cover it. -- res.profile.blur Put the profile details (handle, display name, bio) behind a warning cover. -- res.profile.noOverride Do not allow the profile's blur cover to be lifted. -- res.profile.alert Add a warning to the profile but do not cover it. -- res.avatar.blur Put the avatar behind a cover. -- res.avatar.noOverride Do not allow the avatars's blur cover to be lifted. -- res.avatar.alert Put a warning icon on the avatar. - -Key: - -- ❌ = Filter Content -- 🚫 = Blur (no-override) -- ✋ = Blur -- 🪧 = Alert - -## Scenarios - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ScenarioFilterAccountProfileAvatar
Imperative label ('!hide') on account -❌ - -🚫 - - - - -🚫 - -
Imperative label ('!hide') on profile - - - - -🚫 - - -🚫 - -
Imperative label ('!no-promote') on account -❌ - - - - - - -
Imperative label ('!no-promote') on profile - - - - - - - -
Imperative label ('!warn') on account - - -✋ - - - - -✋ - -
Imperative label ('!warn') on profile - - - - -✋ - - -✋ - -
Imperative label ('!no-unauthenticated') on account when logged out -❌ - -🚫 - - - - -🚫 - -
Imperative label ('!no-unauthenticated') on profile when logged out -❌ - -🚫 - - -🚫 - - -🚫 - -
Imperative label ('!no-unauthenticated') on account when logged in - - - - - - - -
Imperative label ('!no-unauthenticated') on profile when logged in - - - - - - - -
ScenarioFilterAccountProfileAvatar
Blur label ('intolerant') on account (hide) -❌ - -✋ - - - - -✋ - -
Blur label ('intolerant') on profile (hide) - - - - -✋ - - -✋ - -
Blur label ('intolerant') on account (warn) - - -✋ - - - - -✋ - -
Blur label ('intolerant') on profile (warn) - - - - -✋ - - -✋ - -
Blur label ('intolerant') on account (ignore) - - - - - - - -
Blur label ('intolerant') on profile (ignore) - - - - - - - -
ScenarioFilterAccountProfileAvatar
Blur-media label ('porn') on account (hide) -❌ - -✋ - - - - -✋ - -
Blur-media label ('porn') on profile (hide) - - - - - - -✋ - -
Blur-media label ('porn') on account (warn) - - -✋ - - - - -✋ - -
Blur-media label ('porn') on profile (warn) - - - - - - -✋ - -
Blur-media label ('porn') on account (ignore) - - - - - - - -
Blur-media label ('porn') on profile (ignore) - - - - - - - -
ScenarioFilterAccountProfileAvatar
Notice label ('scam') on account (hide) -❌ - - -🪧 - - - - - -🪧 - -
Notice label ('scam') on profile (hide) - - - - - -🪧 - - - -🪧 - -
Notice label ('scam') on account (warn) - - - -🪧 - - - - - -🪧 - -
Notice label ('scam') on profile (warn) - - - - - -🪧 - - - -🪧 - -
Notice label ('scam') on account (ignore) - - - - - - - -
Notice label ('scam') on profile (ignore) - - - - - - - -
ScenarioFilterAccountProfileAvatar
Adult-only label on account when adult content is disabled -❌ - -🚫 - - - - -🚫 - -
Adult-only label on profile when adult content is disabled - - - - - - -🚫 - -
ScenarioFilterAccountProfileAvatar
Self-profile: !hide on account - - - -🪧 - - - - - -🪧 - -
Self-profile: !hide on profile - - - - - -🪧 - - - -🪧 - -
ScenarioFilterAccountProfileAvatar
Mute/block: Blocking user -❌ - - - - - -🚫 - -
Mute/block: Blocking-by-list user -❌ - - - - - -🚫 - -
Mute/block: Blocked by user -❌ - - - - - -🚫 - -
Mute/block: Muted user -❌ - - - - - - -
Mute/block: Muted-by-list user -❌ - - - - - - -
ScenarioFilterAccountProfileAvatar
Prioritization: blocking & blocked-by user -❌ - - - - - -🚫 - -
Prioritization: '!hide' label on account of blocked user -❌ - -🚫 - - - - -🚫 - -
Prioritization: '!hide' and 'intolerant' labels on account (hide) -❌ - -🚫 - - - - -🚫 - -
Prioritization: '!warn' and 'intolerant' labels on account (hide) -❌ - -✋ - - - - -✋ - -
Prioritization: '!warn' and 'porn' labels on account (hide) -❌ - -✋ - - - - -✋ - -
Prioritization: intolerant label on account (hide) and scam label on profile (warn) -❌ - -✋ - - - -🪧 - - -✋ -🪧 -
Prioritization: !hide on account, !warn on profile -❌ - -🚫 - - -✋ - - -🚫 - -
Prioritization: !warn on account, !hide on profile - - -✋ - - -🚫 - - -🚫 - -
diff --git a/packages/api/docs/moderation.md b/packages/api/docs/moderation.md index 0c722dffcb1..7e8d09dd1aa 100644 --- a/packages/api/docs/moderation.md +++ b/packages/api/docs/moderation.md @@ -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 diff --git a/packages/api/scripts/code/label-groups.mjs b/packages/api/scripts/code/label-groups.mjs deleted file mode 100644 index 2c62cbb10b7..00000000000 --- a/packages/api/scripts/code/label-groups.mjs +++ /dev/null @@ -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 {} diff --git a/packages/api/scripts/code/labels.mjs b/packages/api/scripts/code/labels.mjs index 9880afab1ad..2bc8e93fdf0 100644 --- a/packages/api/scripts/code/labels.mjs +++ b/packages/api/scripts/code/labels.mjs @@ -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 type KnownLabelValue = ${labelsDef + .map((label) => `"${label.identifier}"`) + .join(' | ')} + + export const DEFAULT_LABEL_SETTINGS: Record = ${JSON.stringify( + Object.fromEntries( + labelsDef + .filter((label) => label.configurable) + .map((label) => [label.identifier, label.defaultSetting]), + ), + )} - export const LABELS: LabelDefinitionMap = ${JSON.stringify( - genDefMap(), + export const LABELS: Record = ${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 {} diff --git a/packages/api/scripts/docs/labels.mjs b/packages/api/scripts/docs/labels.mjs index 1e1d0d7a6a6..e8986b54030 100644 --- a/packages/api/scripts/docs/labels.mjs +++ b/packages/api/scripts/docs/labels.mjs @@ -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. - warn Provide some form of warning on the content (see "On Warn" behavior). - hide Remove the content from feeds and apply the warning when directly viewed. -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. - - ${labelsRef()} -
IDGroupPreferences Configurable Flags On Warn
- -## Label Group Descriptions - - - - - - - ${labelGroupsDesc()} -
IDDescription
- -## Label Descriptions - - - - - - - ${labelsDesc()} -
IDDescription
- ` + ` } 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` - ${label.id} - ${group.id} - ${label.preferences.join(', ')} - ${group.configurable ? '✅' : '❌'} + ${label.identifier} + ${ + label.configurable ? '✅' : `❌ (${label.fixedPreference})` + } ${label.flags.join(', ')} ${label.onwarn} `) - } - } - return lines.join('\n') -} - -function labelGroupsDesc() { - const lines = [] - for (const id in labelGroupsEn) { - lines.push(stripIndent` - - ${id} - general
${labelGroupsEn[id].name}
${labelGroupsEn[id].description} - - `) - } - return lines.join('\n') -} - -function labelsDesc() { - const lines = [] - for (const id in labelsEn) { - lines.push(stripIndent` - - ${id} - - general
${labelsEn[id].settings.name}
${labelsEn[id].settings.description}

- on an account
${labelsEn[id].account.name}
${labelsEn[id].account.description}

- on content
${labelsEn[id].content.name}
${labelsEn[id].content.description}

- - - `) } return lines.join('\n') } diff --git a/packages/api/scripts/docs/post-moderation-behaviors.mjs b/packages/api/scripts/docs/post-moderation-behaviors.mjs deleted file mode 100644 index 315799831f3..00000000000 --- a/packages/api/scripts/docs/post-moderation-behaviors.mjs +++ /dev/null @@ -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` - - -# Post moderation behaviors - -This document is a reference for the expected behaviors for a post in the application based on some given scenarios. The moderatePost() command condense down to the following yes or no decisions: - -- res.content.filter Do not show the post in feeds. -- res.content.blur Put the post behind a warning cover. -- res.content.noOverride Do not allow the post's blur cover to be lifted. -- res.content.alert Add a warning to the post but do not cover it. -- res.avatar.blur Put the avatar behind a cover. -- res.avatar.noOverride Do not allow the avatars's blur cover to be lifted. -- res.avatar.alert Put a warning icon on the avatar. -- res.embed.blur Put the embed content (media, quote post) behind a warning cover. -- res.embed.noOverride Do not allow the embed's blur cover to be lifted. -- res.embed.alert Put a warning on the embed content (media, quote post). - -Key: - -- ❌ = Filter Content -- 🚫 = Blur (no-override) -- ✋ = Blur -- 🪧 = Alert - -## Scenarios - - - ${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('')} -
- ` -} - -function postTableHead() { - return `ScenarioFilterContentAvatarEmbed` -} - -function scenarioSection(title, scenario) { - return stripIndents` - ${title} - - ${filter(scenario.behaviors.content?.filter)} - - - ${blur( - scenario.behaviors.content?.blur, - scenario.behaviors.content?.noOverride, - )}${alert(scenario.behaviors.content?.alert)} - - - ${blur( - scenario.behaviors.avatar?.blur, - scenario.behaviors.avatar?.noOverride, - )}${alert(scenario.behaviors.avatar?.alert)} - - - ${blur( - scenario.behaviors.embed?.blur, - scenario.behaviors.embed?.noOverride, - )}${alert(scenario.behaviors.embed?.alert)} - - ` -} - -function filter(val) { - return val ? '❌' : '' -} - -function blur(val, noOverride) { - if (val) { - return noOverride ? '🚫' : '✋' - } - return '' -} - -function alert(val) { - return val ? '🪧' : '' -} - -export {} diff --git a/packages/api/scripts/docs/profile-moderation-behaviors.mjs b/packages/api/scripts/docs/profile-moderation-behaviors.mjs deleted file mode 100644 index 413d2593011..00000000000 --- a/packages/api/scripts/docs/profile-moderation-behaviors.mjs +++ /dev/null @@ -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` - - - # Profile moderation behaviors - - This document is a reference for the expected behaviors for a profile in the application based on some given scenarios. The moderateProfile() command condense down to the following yes or no decisions: - - - res.account.filter Do not show the account in feeds. - - res.account.blur Put the account (in listings, when viewing) behind a warning cover. - - res.account.noOverride Do not allow the account's blur cover to be lifted. - - res.account.alert Add a warning to the account but do not cover it. - - res.profile.blur Put the profile details (handle, display name, bio) behind a warning cover. - - res.profile.noOverride Do not allow the profile's blur cover to be lifted. - - res.profile.alert Add a warning to the profile but do not cover it. - - res.avatar.blur Put the avatar behind a cover. - - res.avatar.noOverride Do not allow the avatars's blur cover to be lifted. - - res.avatar.alert Put a warning icon on the avatar. - - Key: - - - ❌ = Filter Content - - 🚫 = Blur (no-override) - - ✋ = Blur - - 🪧 = Alert - - ## Scenarios - - - ${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')} -
- ` -} - -function postTableHead() { - return `ScenarioFilterAccountProfileAvatar` -} - -function scenarioSection(title, scenario) { - return stripIndents` - - ${title} - - ${filter(scenario.behaviors.account?.filter)} - - - ${blur( - scenario.behaviors.account?.blur, - scenario.behaviors.account?.noOverride, - )} - ${alert(scenario.behaviors.account?.alert)} - - - ${blur( - scenario.behaviors.profile?.blur, - scenario.behaviors.profile?.noOverride, - )} - ${alert(scenario.behaviors.profile?.alert)} - - - ${blur( - scenario.behaviors.avatar?.blur, - scenario.behaviors.avatar?.noOverride, - )} - ${alert(scenario.behaviors.avatar?.alert)} - - - ` -} - -function filter(val) { - return val ? '❌' : '' -} - -function blur(val, noOverride) { - if (val) { - return noOverride ? '🚫' : '✋' - } - return '' -} - -function alert(val) { - return val ? '🪧' : '' -} - -export {} diff --git a/packages/api/scripts/generate-code.mjs b/packages/api/scripts/generate-code.mjs index 287d9beb393..bf182eb7774 100644 --- a/packages/api/scripts/generate-code.mjs +++ b/packages/api/scripts/generate-code.mjs @@ -1,4 +1,3 @@ import './code/labels.mjs' -import './code/label-groups.mjs' export {} diff --git a/packages/api/scripts/generate-docs.mjs b/packages/api/scripts/generate-docs.mjs index f7d4a1b2424..6259f745fad 100644 --- a/packages/api/scripts/generate-docs.mjs +++ b/packages/api/scripts/generate-docs.mjs @@ -1,5 +1,3 @@ import './docs/labels.mjs' -import './docs/post-moderation-behaviors.mjs' -import './docs/profile-moderation-behaviors.mjs' export {} diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index d46e2aa9e3c..cfbd2d75c59 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -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) { + private _addHeaders(reqHeaders: Record) { 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, ) } diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index ba625d690c1..57cc86cf8dd 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -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 & Omit, @@ -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, diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 205a986e666..0db3e20e4f9 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -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 { + 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, + ): 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, + ): 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, + ): 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, + headers?: Record, + ): Promise { + await this._service.xrpc.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'app.bsky.labeler.service', ...params }, + { headers }, + ) + } +} + export class AppBskyNotificationNS { _service: AtpServiceClient diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index d79d84cbb34..635e9e19e60 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -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', diff --git a/packages/api/src/client/types/app/bsky/actor/defs.ts b/packages/api/src/client/types/app/bsky/actor/defs.ts index 45b3826d5cd..7eebedc47f4 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -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) +} diff --git a/packages/api/src/client/types/app/bsky/embed/record.ts b/packages/api/src/client/types/app/bsky/embed/record.ts index 388687fd665..65afc16c4a3 100644 --- a/packages/api/src/client/types/app/bsky/embed/record.ts +++ b/packages/api/src/client/types/app/bsky/embed/record.ts @@ -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 } diff --git a/packages/api/src/client/types/app/bsky/labeler/defs.ts b/packages/api/src/client/types/app/bsky/labeler/defs.ts new file mode 100644 index 00000000000..3d9b1d77f8a --- /dev/null +++ b/packages/api/src/client/types/app/bsky/labeler/defs.ts @@ -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) +} diff --git a/packages/api/src/client/types/app/bsky/labeler/getServices.ts b/packages/api/src/client/types/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..8a7db1adbf3 --- /dev/null +++ b/packages/api/src/client/types/app/bsky/labeler/getServices.ts @@ -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 +} diff --git a/packages/api/src/client/types/app/bsky/labeler/service.ts b/packages/api/src/client/types/app/bsky/labeler/service.ts new file mode 100644 index 00000000000..818249468ec --- /dev/null +++ b/packages/api/src/client/types/app/bsky/labeler/service.ts @@ -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) +} diff --git a/packages/api/src/client/types/com/atproto/label/defs.ts b/packages/api/src/client/types/com/atproto/label/defs.ts index 54402204c61..c1641432c3a 100644 --- a/packages/api/src/client/types/com/atproto/label/defs.ts +++ b/packages/api/src/client/types/com/atproto/label/defs.ts @@ -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 & {}) diff --git a/packages/api/src/const.ts b/packages/api/src/const.ts new file mode 100644 index 00000000000..1513c9d1ef9 --- /dev/null +++ b/packages/api/src/const.ts @@ -0,0 +1 @@ +export const BSKY_MODSERVICE_DID = 'did:plc:ar7c4by46qjdydhdevvrndac' diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 87cf1ccf01a..64fbb557f43 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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' diff --git a/packages/api/src/mocker.ts b/packages/api/src/mocker.ts new file mode 100644 index 00000000000..d608c8a1abe --- /dev/null +++ b/packages/api/src/mocker.ts @@ -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(), + } + }, +} diff --git a/packages/api/src/moderation/accumulator.ts b/packages/api/src/moderation/accumulator.ts deleted file mode 100644 index f1e27db1011..00000000000 --- a/packages/api/src/moderation/accumulator.ts +++ /dev/null @@ -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 - } -} diff --git a/packages/api/src/moderation/const/label-groups.ts b/packages/api/src/moderation/const/label-groups.ts deleted file mode 100644 index 564721c7930..00000000000 --- a/packages/api/src/moderation/const/label-groups.ts +++ /dev/null @@ -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.', - }, - }, - }, - }, -} diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts index cbce29cdd7d..624bb5fa395 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -1,859 +1,259 @@ /** this doc is generated by ./scripts/code/labels.mjs **/ -import { LabelDefinitionMap } from '../types' +import { InterprettedLabelValueDefinition, LabelPreference } from '../types' -export const LABELS: LabelDefinitionMap = { - '!hide': { - id: '!hide', - preferences: ['hide'], - flags: ['no-override'], - onwarn: 'blur', - groupId: 'system', - configurable: false, - strings: { - settings: { - en: { - name: 'Moderator Hide', - description: 'Moderator has chosen to hide the content.', - }, - }, - account: { - en: { - name: 'Content Blocked', - description: 'This account has been hidden by the moderators.', - }, - }, - content: { - en: { - name: 'Content Blocked', - description: 'This content has been hidden by the moderators.', - }, - }, - }, - }, - '!no-promote': { - id: '!no-promote', - preferences: ['hide'], - flags: [], - onwarn: null, - groupId: 'system', - configurable: false, - strings: { - settings: { - en: { - name: 'Moderator Filter', - description: 'Moderator has chosen to filter the content from feeds.', - }, - }, - account: { - en: { - name: 'N/A', - description: 'N/A', - }, - }, - content: { - en: { - name: 'N/A', - description: 'N/A', - }, - }, - }, - }, - '!warn': { - id: '!warn', - preferences: ['warn'], - flags: [], - onwarn: 'blur', - groupId: 'system', - configurable: false, - strings: { - settings: { - en: { - name: 'Moderator Warn', - description: - 'Moderator has chosen to set a general warning on the content.', - }, - }, - account: { - en: { - name: 'Content Warning', - description: - 'This account has received a general warning from moderators.', - }, - }, - content: { - en: { - name: 'Content Warning', - description: - 'This content has received a general warning from moderators.', - }, - }, - }, - }, - '!no-unauthenticated': { - id: '!no-unauthenticated', - preferences: ['hide'], - flags: ['no-override', 'unauthed'], - onwarn: 'blur', - groupId: 'system', - configurable: false, - strings: { - settings: { - en: { - name: 'Sign-in Required', - description: - 'This user has requested that their account only be shown to signed-in users.', - }, - }, - account: { - en: { - name: 'Sign-in Required', - description: - 'This user has requested that their account only be shown to signed-in users.', - }, - }, - content: { - en: { - name: 'Sign-in Required', - description: - 'This user has requested that their content only be shown to signed-in users.', - }, - }, - }, - }, - 'dmca-violation': { - id: 'dmca-violation', - preferences: ['hide'], - flags: ['no-override'], - onwarn: 'blur', - groupId: 'legal', - configurable: false, - strings: { - settings: { - en: { - name: 'Copyright Violation', - description: 'The content has received a DMCA takedown request.', - }, - }, - account: { - en: { - name: 'Copyright Violation', - description: - 'This account has received a DMCA takedown request. It will be restored if the concerns can be resolved.', - }, - }, - content: { - en: { - name: 'Copyright Violation', - description: - 'This content has received a DMCA takedown request. It will be restored if the concerns can be resolved.', - }, - }, - }, - }, - doxxing: { - id: 'doxxing', - preferences: ['hide'], - flags: ['no-override'], - onwarn: 'blur', - groupId: 'legal', - configurable: false, - strings: { - settings: { - en: { - name: 'Doxxing', - description: - 'Information that reveals private information about someone which has been shared without the consent of the subject.', - }, - }, - account: { - en: { - name: 'Doxxing', - description: - 'This account has been reported to publish private information about someone without their consent. This report is currently under review.', - }, - }, - content: { - en: { - name: 'Doxxing', - description: - 'This content has been reported to include private information about someone without their consent.', - }, - }, - }, - }, - porn: { - id: 'porn', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'sexual', - configurable: true, - strings: { - settings: { - en: { - 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: { - en: { - name: 'Adult Content', - description: - 'This account contains imagery of full-frontal nudity or explicit sexual activity.', - }, - }, - content: { - en: { - name: 'Adult Content', - description: - 'This content contains imagery of full-frontal nudity or explicit sexual activity.', - }, - }, - }, - }, - sexual: { - id: 'sexual', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'sexual', - configurable: true, - strings: { - settings: { - en: { - 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: { - en: { - name: 'Suggestive Content', - description: - 'This account contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.', - }, - }, - content: { - en: { - name: 'Suggestive Content', - description: - 'This content contains imagery which is sexually suggestive. Common examples include selfies in underwear or in partial undress.', - }, - }, - }, - }, - nudity: { - id: 'nudity', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'sexual', - configurable: true, - strings: { - settings: { - en: { - 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: { - en: { - name: 'Adult Content', - description: - 'This account contains imagery which portrays nudity in a non-sexual or artistic setting.', - }, - }, - content: { - en: { - name: 'Adult Content', - description: - 'This content contains imagery which portrays nudity in a non-sexual or artistic setting.', - }, - }, - }, - }, - nsfl: { - id: 'nsfl', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'violence', - configurable: true, - strings: { - settings: { - en: { - name: 'NSFL', - description: - '"Not Suitable For Life." This includes graphic images like the infamous "goatse" (don\'t look it up).', - }, - }, - account: { - en: { - name: 'Graphic Imagery (NSFL)', - description: - 'This account contains graphic images which are often referred to as "Not Suitable For Life."', - }, - }, - content: { - en: { - name: 'Graphic Imagery (NSFL)', - description: - 'This content contains graphic images which are often referred to as "Not Suitable For Life."', - }, - }, - }, - }, - corpse: { - id: 'corpse', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'violence', - configurable: true, - strings: { - settings: { - en: { - 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: { - en: { - name: 'Graphic Imagery (Corpse)', - description: - 'This account contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.', - }, - }, - content: { - en: { - name: 'Graphic Imagery (Corpse)', - description: - 'This content contains images of a dead human body in any context. Includes war images, hanging, funeral caskets.', - }, - }, - }, - }, - gore: { - id: 'gore', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'violence', - configurable: true, - strings: { - settings: { - en: { - name: 'Gore', - description: - 'Intended for shocking images, typically involving blood or visible wounds.', - }, - }, - account: { - en: { - name: 'Graphic Imagery (Gore)', - description: - 'This account contains shocking images involving blood or visible wounds.', - }, - }, - content: { - en: { - name: 'Graphic Imagery (Gore)', - description: - 'This content contains shocking images involving blood or visible wounds.', - }, - }, - }, - }, - torture: { - id: 'torture', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur', - groupId: 'violence', - configurable: true, - strings: { - settings: { - en: { - name: 'Torture', - description: - 'Depictions of torture of a human or animal (animal cruelty).', - }, - }, - account: { - en: { - name: 'Graphic Imagery (Torture)', - description: - 'This account contains depictions of torture of a human or animal.', - }, - }, - content: { - en: { - name: 'Graphic Imagery (Torture)', - description: - 'This content contains depictions of torture of a human or animal.', - }, - }, - }, - }, - 'self-harm': { - id: 'self-harm', - preferences: ['ignore', 'warn', 'hide'], - flags: ['adult'], - onwarn: 'blur-media', - groupId: 'violence', - configurable: true, - strings: { - settings: { - en: { - name: 'Self-Harm', - description: - 'A visual depiction (photo or figurative) of cutting, suicide, or similar.', - }, - }, - account: { - en: { - name: 'Graphic Imagery (Self-Harm)', - description: - 'This account includes depictions of cutting, suicide, or other forms of self-harm.', - }, - }, - content: { - en: { - name: 'Graphic Imagery (Self-Harm)', - description: - 'This content includes depictions of cutting, suicide, or other forms of self-harm.', - }, - }, +export type KnownLabelValue = + | '!hide' + | '!no-promote' + | '!warn' + | '!no-unauthenticated' + | 'dmca-violation' + | 'doxxing' + | 'porn' + | 'sexual' + | 'nudity' + | 'gore' + +export const DEFAULT_LABEL_SETTINGS: Record = { + porn: 'hide', + sexual: 'warn', + nudity: 'warn', + gore: 'warn', +} + +export const LABELS: Record = + { + '!hide': { + identifier: '!hide', + configurable: false, + 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', + }, + profile: { + avatar: 'blur', + banner: 'blur', + displayName: 'blur', + }, + content: { + contentList: 'blur', + contentView: 'blur', + }, + }, + locales: [], }, - }, - 'intolerant-race': { - id: 'intolerant-race', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - name: 'Racial Intolerance', - description: 'Hateful or intolerant content related to race.', - }, - }, - account: { - en: { - name: 'Intolerance (Racial)', - description: - 'This account includes hateful or intolerant content related to race.', - }, - }, - content: { - en: { - name: 'Intolerance (Racial)', - description: - 'This content includes hateful or intolerant views related to race.', - }, - }, + '!no-promote': { + identifier: '!no-promote', + configurable: false, + defaultSetting: 'hide', + flags: ['no-self'], + severity: 'none', + blurs: 'none', + behaviors: {}, + locales: [], }, - }, - 'intolerant-gender': { - id: 'intolerant-gender', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - name: 'Gender Intolerance', - description: - 'Hateful or intolerant content related to gender or gender identity.', - }, - }, - account: { - en: { - name: 'Intolerance (Gender)', - description: - 'This account includes hateful or intolerant content related to gender or gender identity.', - }, - }, - content: { - en: { - name: 'Intolerance (Gender)', - description: - 'This content includes hateful or intolerant views related to gender or gender identity.', - }, - }, + '!warn': { + 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', + }, + profile: { + avatar: 'blur', + banner: 'blur', + displayName: 'blur', + }, + content: { + contentList: 'blur', + contentView: 'blur', + }, + }, + locales: [], }, - }, - 'intolerant-sexual-orientation': { - id: 'intolerant-sexual-orientation', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - name: 'Sexual Orientation Intolerance', - description: - 'Hateful or intolerant content related to sexual preferences.', - }, - }, - account: { - en: { - name: 'Intolerance (Orientation)', - description: - 'This account includes hateful or intolerant content related to sexual preferences.', - }, - }, - content: { - en: { - name: 'Intolerance (Orientation)', - description: - 'This content includes hateful or intolerant views related to sexual preferences.', - }, - }, + '!no-unauthenticated': { + 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', + }, + profile: { + avatar: 'blur', + banner: 'blur', + displayName: 'blur', + }, + content: { + contentList: 'blur', + contentView: 'blur', + }, + }, + locales: [], }, - }, - 'intolerant-religion': { - id: 'intolerant-religion', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - name: 'Religious Intolerance', - description: - 'Hateful or intolerant content related to religious views or practices.', - }, - }, - account: { - en: { - name: 'Intolerance (Religious)', - description: - 'This account includes hateful or intolerant content related to religious views or practices.', - }, - }, - content: { - en: { - name: 'Intolerance (Religious)', - description: - 'This content includes hateful or intolerant views related to religious views or practices.', - }, - }, + 'dmca-violation': { + identifier: 'dmca-violation', + configurable: false, + defaultSetting: 'hide', + flags: ['no-override', 'no-self'], + severity: 'none', + blurs: 'content', + behaviors: { + account: { + profileList: 'blur', + profileView: 'blur', + contentList: 'blur', + contentView: 'blur', + }, + profile: { + profileList: 'blur', + profileView: 'blur', + }, + content: { + contentList: 'blur', + contentView: 'blur', + }, + }, + locales: [], }, - }, - intolerant: { - id: 'intolerant', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - name: 'Intolerance', - description: - 'A catchall for hateful or intolerant content which is not covered elsewhere.', - }, - }, - account: { - en: { - name: 'Intolerance', - description: 'This account includes hateful or intolerant content.', - }, - }, - content: { - en: { - name: 'Intolerance', - description: 'This content includes hateful or intolerant views.', - }, - }, + doxxing: { + identifier: 'doxxing', + configurable: false, + defaultSetting: 'hide', + flags: ['no-override', 'no-self'], + severity: 'none', + blurs: 'content', + behaviors: { + account: { + profileList: 'blur', + profileView: 'blur', + contentList: 'blur', + contentView: 'blur', + }, + profile: { + profileList: 'blur', + profileView: 'blur', + }, + content: { + contentList: 'blur', + contentView: 'blur', + }, + }, + locales: [], }, - }, - 'icon-intolerant': { - id: 'icon-intolerant', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur-media', - groupId: 'intolerance', - configurable: true, - strings: { - settings: { - en: { - 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: { - en: { - 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: { - en: { - 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.', - }, - }, + porn: { + identifier: 'porn', + configurable: true, + defaultSetting: 'hide', + flags: ['adult'], + severity: 'none', + blurs: 'media', + behaviors: { + account: { + avatar: 'blur', + banner: 'blur', + }, + profile: { + avatar: 'blur', + banner: 'blur', + }, + content: { + contentMedia: 'blur', + }, + }, + locales: [], }, - }, - threat: { - id: 'threat', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'rude', - configurable: true, - strings: { - settings: { - en: { - name: 'Threats', - description: - 'Statements or imagery published with the intent to threaten, intimidate, or harm.', - }, - }, - account: { - en: { - name: 'Threats', - description: - 'The moderators believe this account has published statements or imagery with the intent to threaten, intimidate, or harm others.', - }, - }, - content: { - en: { - name: 'Threats', - description: - 'The moderators believe this content was published with the intent to threaten, intimidate, or harm others.', - }, - }, + sexual: { + identifier: 'sexual', + configurable: true, + defaultSetting: 'warn', + flags: ['adult'], + severity: 'none', + blurs: 'media', + behaviors: { + account: { + avatar: 'blur', + banner: 'blur', + }, + profile: { + avatar: 'blur', + banner: 'blur', + }, + content: { + contentMedia: 'blur', + }, + }, + locales: [], }, - }, - spoiler: { - id: 'spoiler', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'curation', - configurable: true, - strings: { - settings: { - en: { - name: 'Spoiler', - description: - 'Discussion about film, TV, etc which gives away plot points.', - }, - }, - account: { - en: { - name: 'Spoiler Warning', - description: - 'This account contains discussion about film, TV, etc which gives away plot points.', - }, - }, - content: { - en: { - name: 'Spoiler Warning', - description: - 'This content contains discussion about film, TV, etc which gives away plot points.', - }, - }, - }, - }, - spam: { - id: 'spam', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'spam', - configurable: true, - strings: { - settings: { - en: { - name: 'Spam', - description: - 'Repeat, low-quality messages which are clearly not designed to add to a conversation or space.', - }, - }, - account: { - en: { - name: 'Spam', - description: - 'This account publishes repeat, low-quality messages which are clearly not designed to add to a conversation or space.', - }, - }, - content: { - en: { - 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.', - }, - }, + nudity: { + identifier: 'nudity', + configurable: true, + defaultSetting: 'warn', + flags: ['adult'], + severity: 'none', + blurs: 'media', + behaviors: { + account: { + avatar: 'blur', + banner: 'blur', + }, + profile: { + avatar: 'blur', + banner: 'blur', + }, + content: { + contentMedia: 'blur', + }, + }, + locales: [], }, - }, - 'account-security': { - id: 'account-security', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'misinfo', - configurable: true, - strings: { - settings: { - en: { - name: 'Security Concerns', - description: - 'Content designed to hijack user accounts such as a phishing attack.', - }, - }, - account: { - en: { - name: 'Security Warning', - description: - 'This account has published content designed to hijack user accounts such as a phishing attack.', - }, - }, - content: { - en: { - name: 'Security Warning', - description: - 'This content is designed to hijack user accounts such as a phishing attack.', - }, - }, + gore: { + 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', + }, + }, + locales: [], }, - }, - 'net-abuse': { - id: 'net-abuse', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'blur', - groupId: 'misinfo', - configurable: true, - strings: { - settings: { - en: { - name: 'Network Attacks', - description: - 'Content designed to attack network systems such as denial-of-service attacks.', - }, - }, - account: { - en: { - name: 'Network Attack Warning', - description: - 'This account has published content designed to attack network systems such as denial-of-service attacks.', - }, - }, - content: { - en: { - name: 'Network Attack Warning', - description: - 'This content is designed to attack network systems such as denial-of-service attacks.', - }, - }, - }, - }, - impersonation: { - id: 'impersonation', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'alert', - groupId: 'misinfo', - configurable: true, - strings: { - settings: { - en: { - name: 'Impersonation', - description: 'Accounts which falsely assert some identity.', - }, - }, - account: { - en: { - name: 'Impersonation Warning', - description: - 'The moderators believe this account is lying about their identity.', - }, - }, - content: { - en: { - name: 'Impersonation Warning', - description: - 'The moderators believe this account is lying about their identity.', - }, - }, - }, - }, - scam: { - id: 'scam', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'alert', - groupId: 'misinfo', - configurable: true, - strings: { - settings: { - en: { - name: 'Scam', - description: 'Fraudulent content.', - }, - }, - account: { - en: { - name: 'Scam Warning', - description: - 'The moderators believe this account publishes fraudulent content.', - }, - }, - content: { - en: { - name: 'Scam Warning', - description: 'The moderators believe this is fraudulent content.', - }, - }, - }, - }, - misleading: { - id: 'misleading', - preferences: ['ignore', 'warn', 'hide'], - flags: [], - onwarn: 'alert', - groupId: 'misinfo', - configurable: true, - strings: { - settings: { - en: { - name: 'Misleading', - description: 'Accounts which share misleading information.', - }, - }, - account: { - en: { - name: 'Misleading', - description: - 'The moderators believe this account is spreading misleading information.', - }, - }, - content: { - en: { - name: 'Misleading', - description: - 'The moderators believe this account is spreading misleading information.', - }, - }, - }, - }, -} + } diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts new file mode 100644 index 00000000000..58ce07615b8 --- /dev/null +++ b/packages/api/src/moderation/decision.ts @@ -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 +} diff --git a/packages/api/src/moderation/index.ts b/packages/api/src/moderation/index.ts index 4ca38c1fc60..2b7a1e9164c 100644 --- a/packages/api/src/moderation/index.ts +++ b/packages/api/src/moderation/index.ts @@ -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, -} from './util' +import { ModerationDecision } from './decision' -// profiles -// = - -export interface ProfileModeration { - decisions: { - account: ModerationDecision - profile: ModerationDecision - } - account: ModerationUI - profile: ModerationUI - avatar: ModerationUI -} +export { ModerationUI } from './ui' +export { ModerationDecision } from './decision' +export { + interpretLabelValueDefinition, + interpretLabelValueDefinitions, +} from './util' 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) } diff --git a/packages/api/src/moderation/subjects/account.ts b/packages/api/src/moderation/subjects/account.ts index d8cda1b6408..a1c873b7dd5 100644 --- a/packages/api/src/moderation/subjects/account.ts +++ b/packages/api/src/moderation/subjects/account.ts @@ -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[] { diff --git a/packages/api/src/moderation/subjects/feed-generator.ts b/packages/api/src/moderation/subjects/feed-generator.ts index ad1cde8d0de..d87e62e9044 100644 --- a/packages/api/src/moderation/subjects/feed-generator.ts +++ b/packages/api/src/moderation/subjects/feed-generator.ts @@ -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() } diff --git a/packages/api/src/moderation/subjects/notification.ts b/packages/api/src/moderation/subjects/notification.ts new file mode 100644 index 00000000000..305dd209890 --- /dev/null +++ b/packages/api/src/moderation/subjects/notification.ts @@ -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 +} diff --git a/packages/api/src/moderation/subjects/post.ts b/packages/api/src/moderation/subjects/post.ts index 577b5374df1..f93df9b92d9 100644 --- a/packages/api/src/moderation/subjects/post.ts +++ b/packages/api/src/moderation/subjects/post.ts @@ -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 } diff --git a/packages/api/src/moderation/subjects/profile.ts b/packages/api/src/moderation/subjects/profile.ts index 0025bf690e5..f76e2bfa730 100644 --- a/packages/api/src/moderation/subjects/profile.ts +++ b/packages/api/src/moderation/subjects/profile.ts @@ -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[] { diff --git a/packages/api/src/moderation/subjects/quoted-post.ts b/packages/api/src/moderation/subjects/quoted-post.ts deleted file mode 100644 index 6d0f9eb9d52..00000000000 --- a/packages/api/src/moderation/subjects/quoted-post.ts +++ /dev/null @@ -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() -} diff --git a/packages/api/src/moderation/subjects/user-list.ts b/packages/api/src/moderation/subjects/user-list.ts index a437fead036..ad7cd861c49 100644 --- a/packages/api/src/moderation/subjects/user-list.ts +++ b/packages/api/src/moderation/subjects/user-list.ts @@ -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() } diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index b60b3ee593a..e43a8f8e6bf 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -1,72 +1,81 @@ import { AppBskyActorDefs, AppBskyFeedDefs, + AppBskyNotificationListNotifications, AppBskyGraphDefs, ComAtprotoLabelDefs, } from '../client/index' +import { KnownLabelValue } from './const/labels' -// labels +// syntax // = -export type Label = ComAtprotoLabelDefs.Label - -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 const CUSTOM_LABEL_VALUE_RE = /^[a-z-]+$/ -export type LabelDefinitionLocalizedStringsMap = Record< - string, - LabelDefinitionLocalizedStrings -> +// behaviors +// = -export interface LabelDefinition { - id: string - groupId: string - configurable: boolean - preferences: LabelPreference[] - flags: LabelDefinitionFlag[] - onwarn: LabelDefinitionOnWarnBehavior - strings: { - settings: LabelDefinitionLocalizedStringsMap - account: LabelDefinitionLocalizedStringsMap - content: LabelDefinitionLocalizedStringsMap - } +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 interface LabelGroupDefinition { - id: string - configurable: boolean - labels: LabelDefinition[] - strings: { - settings: LabelDefinitionLocalizedStringsMap - } +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 = {} -export type LabelDefinitionMap = Record -export type LabelGroupDefinitionMap = Record - -// labelers +// labels // = -interface Labeler { - did: string - displayName: string -} +export type Label = ComAtprotoLabelDefs.Label +export type LabelTarget = 'account' | 'profile' | 'content' +export type LabelPreference = 'ignore' | 'warn' | 'hide' -export interface LabelerSettings { - labeler: Labeler - labels: Record +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 LabelDefinitionMap = Record< + KnownLabelValue, + InterprettedLabelValueDefinition +> + // 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 - adultContentEnabled: boolean +export interface ModerationPrefsModerator { + did: string labels: Record - labelers: LabelerSettings[] } -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 ModerationPrefs { + adultContentEnabled: boolean + labels: Record + mods: ModerationPrefsModerator[] } -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 } diff --git a/packages/api/src/moderation/ui.ts b/packages/api/src/moderation/ui.ts new file mode 100644 index 00000000000..0bb5d7464f3 --- /dev/null +++ b/packages/api/src/moderation/ui.ts @@ -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 + } +} diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts index b567a886857..e2a8f2251e9 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -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)) +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 7e36e77de58..dac0666a41a 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -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 threadViewPrefs: BskyThreadViewPreference - adultContentEnabled: boolean - contentLabels: Record + moderationPrefs: ModerationPrefs birthDate: Date | undefined interests: BskyInterestsPreference mutedWords: AppBskyActorDefs.MutedWord[] diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index 7bebb8a1bcf..89491cc1616 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -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) => { diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 6f77be38e51..197fc80e560 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -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,8 +294,16 @@ describe('agent', () => { await agent.setAdultContentEnabled(false) 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: { @@ -295,12 +325,18 @@ describe('agent', () => { hiddenPosts: [], }) - await agent.setContentLabelPref('impersonation', 'warn') + await agent.setContentLabelPref('misinfo', 'hide') await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, - adultContentEnabled: false, - contentLabels: { - impersonation: 'warn', + moderationPrefs: { + adultContentEnabled: false, + labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, + mods: [ + { + did: BSKY_MODSERVICE_DID, + labels: {}, + }, + ], }, birthDate: undefined, feedViewPrefs: { @@ -323,14 +359,22 @@ describe('agent', () => { hiddenPosts: [], }) - await agent.setContentLabelPref('spam', 'show') // will convert to 'ignore' - await agent.setContentLabelPref('impersonation', 'hide') + await agent.setContentLabelPref('spam', 'ignore') 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', + 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,66 @@ 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: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, + interests: { + tags: [], + }, + mutedWords: [], + hiddenPosts: [], + }) + + await agent.setContentLabelPref('porn', 'ignore') + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: [], + saved: [], + }, + 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: { @@ -994,15 +1253,24 @@ describe('agent', () => { hiddenPosts: [], }) - await agent.setContentLabelPref('nsfw', 'hide') + await agent.removeModService('did:plc:other') 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: {}, + }, + ], }, 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', diff --git a/packages/api/tests/moderation-behaviors.test.ts b/packages/api/tests/moderation-behaviors.test.ts new file mode 100644 index 00000000000..4f782dd0155 --- /dev/null +++ b/packages/api/tests/moderation-behaviors.test.ts @@ -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), + ) + }, + ) +}) diff --git a/packages/api/tests/moderation-custom-labels.test.ts b/packages/api/tests/moderation-custom-labels.test.ts new file mode 100644 index 00000000000..2d177c3e3de --- /dev/null +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -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: [], + }) +} diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts new file mode 100644 index 00000000000..d97a21941eb --- /dev/null +++ b/packages/api/tests/moderation-prefs.test.ts @@ -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, + }, + }) + }) +}) diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index d9dd98086d8..4a010c41fff 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -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,46 +56,32 @@ 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) - const res1 = moderatePost( - mock.postView({ - record: { - text: 'Hello', - createdAt: new Date().toISOString(), - }, - author: mock.profileViewBasic({ - handle: 'bob.test', - displayName: 'Bob', - }), + 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:bob.test', + src: 'did:web:labeler.test', uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', val: 'porn', cts: new Date().toISOString(), @@ -108,32 +90,82 @@ describe('Moderation', () => { }), { userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'hide', + prefs: { + adultContentEnabled: true, + labels: { + porn: 'hide', + }, + mods: [], }, - 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, + 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), + ) + } + }) - // porn (ignore) - const res2 = moderatePost( + it('Can manually apply hiding', () => { + const res1 = moderatePost( mock.postView({ record: { text: 'Hello', @@ -143,192 +175,262 @@ describe('Moderation', () => { handle: 'bob.test', displayName: 'Bob', }), - labels: [ - { - src: 'did:web:bob.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', - val: 'porn', - cts: new Date().toISOString(), - }, - ], + labels: [], }), { userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'ignore', + prefs: { + adultContentEnabled: true, + labels: {}, + mods: [ + { + did: 'did:web:labeler.test', + labels: {}, + }, + ], }, - 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, + res1.addHidden(true) + expect(res1.ui('contentList')).toBeModerationResult( + ['filter', 'blur'], + 'contentList', ) + expect(res1.ui('contentView')).toBeModerationResult(['blur'], 'contentView') }) - 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', + it('Prioritizes filters and blurs correctly on merge', () => { + const res1 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), labels: [ { src: 'did:web:labeler.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', + 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', - }, - labelers: [ - { - labeler: { + prefs: { + adultContentEnabled: true, + labels: { + porn: 'hide', + }, + mods: [ + { did: 'did:web:labeler.test', - displayName: 'Labeler', + labels: {}, }, - labels: { - porn: 'ignore', - }, - }, - ], + ], + }, }, ) - 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 (hide for labeler, ignore for global) - const res2 = moderateProfile( - mock.profileViewBasic({ - handle: 'bob.test', - displayName: 'Bob', + 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', + 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.actor.profile/self', + uri: 'at://did:web:bob.test/app.bsky.post/fake', val: 'porn', cts: new Date().toISOString(), }, ], }), - { - userDid: 'did:web:alice.test', + 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('Doesnt allow custom behaviors to override imperative labels', () => { + const modOpts = { + userDid: 'did:web:alice.test', + prefs: { adultContentEnabled: true, - labels: { - porn: 'ignore', - }, - labelers: [ + labels: {}, + mods: [ { - labeler: { - did: 'did:web:labeler.test', - displayName: 'Labeler', - }, - labels: { - porn: 'hide', - }, + did: 'did:web:labeler.test', + labels: {}, }, ], }, - ) - 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', + 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:labeler.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', - val: 'porn', + 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: [ { - labeler: { - did: 'did:web:labeler.test', - displayName: 'Labeler', - }, - labels: {}, + did: 'did:web:labeler.test', + labels: { BadLabel: 'hide', 'bad/label': 'hide' }, }, ], }, - ) - 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: '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: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: '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(), }, ], }), - { - userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'hide', - }, - labelers: [], - }, + 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([]) + }) }) diff --git a/packages/api/tests/post-moderation.test.ts b/packages/api/tests/post-moderation.test.ts deleted file mode 100644 index 3d62a720507..00000000000 --- a/packages/api/tests/post-moderation.test.ts +++ /dev/null @@ -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), - ) - }, - ) -}) diff --git a/packages/api/tests/profile-moderation.test.ts b/packages/api/tests/profile-moderation.test.ts deleted file mode 100644 index bca63857c30..00000000000 --- a/packages/api/tests/profile-moderation.test.ts +++ /dev/null @@ -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, - ) - }, - ) -}) diff --git a/packages/api/tests/util/echo-server.ts b/packages/api/tests/util/echo-server.ts new file mode 100644 index 00000000000..428398dbc3f --- /dev/null +++ b/packages/api/tests/util/echo-server.ts @@ -0,0 +1,21 @@ +import http from 'node:http' + +export async function createHeaderEchoServer(port: number) { + return new Promise((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) + }) +} diff --git a/packages/api/tests/util/index.ts b/packages/api/tests/util/index.ts index 2a7c3ba7bbf..50334e8daf8 100644 --- a/packages/api/tests/util/index.ts +++ b/packages/api/tests/util/index.ts @@ -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(), - } - }, -} diff --git a/packages/api/tests/util/moderation-behavior.ts b/packages/api/tests/util/moderation-behavior.ts index cc7a101e11f..07c8310a4d2 100644 --- a/packages/api/tests/util/moderation-behavior.ts +++ b/packages/api/tests/util/moderation-behavior.ts @@ -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 + } +> + +export type SuiteScenarios = Record 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 (!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 inform=${expectedInform}, got ${ + actual.inform || false + }`, + ) } - if (!!actual.alert !== !!expected.alert) { + const expectedAlert = expected.includes('alert') + if (!!actual.alert !== expectedAlert) { return fail( - `${context} expected to be alert=${expected.alert || false}, got ${ + `${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, - }, - ], + ], + }, } } } diff --git a/packages/bsky/proto/bsky.proto b/packages/bsky/proto/bsky.proto index ffff8efa043..3820de7d564 100644 --- a/packages/bsky/proto/bsky.proto +++ b/packages/bsky/proto/bsky.proto @@ -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); diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 386313aa5cb..b7860791507 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -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 } diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 7a71340d892..862ac239c7f 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -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[] } diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 622fcd3891c..db312373ea0 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -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 => { 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 } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index 403be45892a..b7bf881ab3a 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -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, ) => { 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) => { @@ -102,7 +104,7 @@ type Context = { searchAgent?: AtpAgent } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } type Skeleton = { dids: string[] diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 8c7507961a7..0ac1506f86d 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -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, ) => { 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) => { @@ -100,7 +105,7 @@ type Context = { searchAgent?: AtpAgent } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } type Skeleton = { dids: string[] diff --git a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts index b138ae1acad..b94486940fa 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 4f026418bc4..a8b85a3c155 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -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 => { 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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 02e2240828f..a8174b164fc 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -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 => { 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 diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index aae633e4f2a..465348f0522 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -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 } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 57f86af9a28..171c497e2ae 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -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') diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index 34547948204..d8225384c97 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 582dd78a073..5e9ebf7d70c 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index ec182e39ac5..daed2a99025 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -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 => { 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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index cfe59e290fb..d7c4009bdaa 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -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 diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index fc9592ad7f7..fd5b89f96e0 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index fe33e305774..887200476b7 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index 0524f33b0c0..ee42ea74dd3 100644 --- a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -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), ) diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index e7e4bed20a9..e9e31bcaceb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -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 => { 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[] diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 876b3113b9f..c511883fbde 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -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[] diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index 1df10bf5a23..e7a9df2030e 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -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) => { 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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index c771d034cfd..af6e78c22b7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -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, ) => { 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) => { 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 } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 81df38e453e..08e4abebc0f 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -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, ) => { 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) => { 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 } diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index e8204d4ac0d..42c298ecbe2 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index ae4a9a27b59..9b7e74f69b6 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts index 20060a5b9a2..7004e43249f 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts @@ -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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index 0ff90f5c4bf..c53b7e14482 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index 3abd417eb87..db81c46ab1b 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -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) => { 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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 49c4a61c1c8..579a9ab329b 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -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) => { 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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/graph/muteActor.ts b/packages/bsky/src/api/app/bsky/graph/muteActor.ts index 65600344a8d..051e5564b9e 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActor.ts @@ -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]) diff --git a/packages/bsky/src/api/app/bsky/labeler/getServices.ts b/packages/bsky/src/api/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..9f151757361 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/labeler/getServices.ts @@ -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, + }, + } + }, + }) +} diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 6f738fb4cca..6ee8951fc66 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -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, ) => { 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 = { diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index a5a3d8a8cef..c676e115908 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -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), ) diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index 17e5ff2473f..afb71740245 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -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) diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 3b297caf095..b166368cdf8 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -1,3 +1,4 @@ +import express from 'express' import * as plc from '@did-plc/lib' import { IdResolver } from '@atproto/identity' import AtpAgent from '@atproto/api' @@ -8,6 +9,7 @@ import { DataPlaneClient } from './data-plane/client' import { Hydrator } from './hydration/hydrator' import { Views } from './views' import { AuthVerifier } from './auth-verifier' +import { dedupeStrs } from '@atproto/common' import { BsyncClient } from './bsync' import { CourierClient } from './courier' @@ -79,6 +81,17 @@ export class AppContext { keypair: this.signingKey, }) } + + reqLabelers(req: express.Request): string[] { + const val = req.header('atproto-labelers') + if (!val) return this.cfg.labelsFromIssuerDids + return dedupeStrs( + val + .split(',') + .map((did) => did.trim()) + .slice(0, 10), + ) + } } export default AppContext diff --git a/packages/bsky/src/data-plane/server/db/database-schema.ts b/packages/bsky/src/data-plane/server/db/database-schema.ts index e02e07f7ad0..3630d0b1894 100644 --- a/packages/bsky/src/data-plane/server/db/database-schema.ts +++ b/packages/bsky/src/data-plane/server/db/database-schema.ts @@ -33,6 +33,7 @@ import * as suggestedFollow from './tables/suggested-follow' import * as suggestedFeed from './tables/suggested-feed' import * as taggedSuggestion from './tables/tagged-suggestion' import * as blobTakedown from './tables/blob-takedown' +import * as labeler from './tables/labeler' export type DatabaseSchemaType = duplicateRecord.PartialDB & profile.PartialDB & @@ -66,8 +67,9 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB & viewParam.PartialDB & suggestedFollow.PartialDB & suggestedFeed.PartialDB & - taggedSuggestion.PartialDB & - blobTakedown.PartialDB + blobTakedown.PartialDB & + labeler.PartialDB & + taggedSuggestion.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/bsky/src/data-plane/server/db/migrations/20240226T225725627Z-labelers.ts b/packages/bsky/src/data-plane/server/db/migrations/20240226T225725627Z-labelers.ts new file mode 100644 index 00000000000..be39eed65dd --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/migrations/20240226T225725627Z-labelers.ts @@ -0,0 +1,27 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('labeler') + .addColumn('uri', 'varchar', (col) => col.primaryKey()) + .addColumn('cid', 'varchar', (col) => col.notNull()) + .addColumn('creator', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) + .addColumn('sortAt', 'varchar', (col) => + col + .generatedAlwaysAs(sql`least("createdAt", "indexedAt")`) + .stored() + .notNull(), + ) + .execute() + await db.schema + .createIndex('labeler_order_by_idx') + .on('labeler') + .columns(['sortAt', 'cid']) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('labeler').execute() +} diff --git a/packages/bsky/src/data-plane/server/db/migrations/index.ts b/packages/bsky/src/data-plane/server/db/migrations/index.ts index 03f8795b3f7..0c1e7fe2c05 100644 --- a/packages/bsky/src/data-plane/server/db/migrations/index.ts +++ b/packages/bsky/src/data-plane/server/db/migrations/index.ts @@ -33,3 +33,4 @@ export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-index export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status' export * as _20231220T225126090Z from './20231220T225126090Z-blob-takedowns' export * as _20240124T023719200Z from './20240124T023719200Z-tagged-suggestions' +export * as _20240226T225725627Z from './20240226T225725627Z-labelers' diff --git a/packages/bsky/src/data-plane/server/db/tables/labeler.ts b/packages/bsky/src/data-plane/server/db/tables/labeler.ts new file mode 100644 index 00000000000..0f689b534af --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/tables/labeler.ts @@ -0,0 +1,16 @@ +import { GeneratedAlways } from 'kysely' + +export const tableName = 'labeler' + +export interface Labeler { + uri: string + cid: string + creator: string + createdAt: string + indexedAt: string + sortAt: GeneratedAlways +} + +export type PartialDB = { + [tableName]: Labeler +} diff --git a/packages/bsky/src/data-plane/server/indexing/index.ts b/packages/bsky/src/data-plane/server/indexing/index.ts index 68f5ff8b721..743e0380f97 100644 --- a/packages/bsky/src/data-plane/server/indexing/index.ts +++ b/packages/bsky/src/data-plane/server/indexing/index.ts @@ -26,6 +26,7 @@ import * as ListItem from './plugins/list-item' import * as ListBlock from './plugins/list-block' import * as Block from './plugins/block' import * as FeedGenerator from './plugins/feed-generator' +import * as Labeler from './plugins/labeler' import RecordProcessor from './processor' import { subLogger } from '../../../logger' import { retryHttp } from '../../../util/retry' @@ -44,6 +45,7 @@ export class IndexingService { listBlock: ListBlock.PluginType block: Block.PluginType feedGenerator: FeedGenerator.PluginType + labeler: Labeler.PluginType } constructor( @@ -63,6 +65,7 @@ export class IndexingService { listBlock: ListBlock.makePlugin(this.db, this.background), block: Block.makePlugin(this.db, this.background), feedGenerator: FeedGenerator.makePlugin(this.db, this.background), + labeler: Labeler.makePlugin(this.db, this.background), } } @@ -298,6 +301,7 @@ export class IndexingService { .deleteFrom('feed_generator') .where('creator', '=', did) .execute() + await this.db.db.deleteFrom('labeler').where('creator', '=', did).execute() // lists await this.db.db .deleteFrom('list_item') diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts b/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts new file mode 100644 index 00000000000..5efdb0e30ca --- /dev/null +++ b/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts @@ -0,0 +1,77 @@ +import { Selectable } from 'kysely' +import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' +import { CID } from 'multiformats/cid' +import * as Labeler from '../../../../lexicon/types/app/bsky/labeler/service' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import RecordProcessor from '../processor' +import { BackgroundQueue } from '../../background' + +const lexId = lex.ids.AppBskyLabelerService +type IndexedLabeler = Selectable + +const insertFn = async ( + db: DatabaseSchema, + uri: AtUri, + cid: CID, + obj: Labeler.Record, + timestamp: string, +): Promise => { + if (uri.rkey !== 'self') return null + const inserted = await db + .insertInto('labeler') + .values({ + uri: uri.toString(), + cid: cid.toString(), + creator: uri.host, + createdAt: normalizeDatetimeAlways(obj.createdAt), + indexedAt: timestamp, + }) + .onConflict((oc) => oc.doNothing()) + .returningAll() + .executeTakeFirst() + return inserted || null +} + +const findDuplicate = async (): Promise => { + return null +} + +const notifsForInsert = () => { + return [] +} + +const deleteFn = async ( + db: DatabaseSchema, + uri: AtUri, +): Promise => { + const deleted = await db + .deleteFrom('labeler') + .where('uri', '=', uri.toString()) + .returningAll() + .executeTakeFirst() + return deleted || null +} + +const notifsForDelete = () => { + return { notifs: [], toDelete: [] } +} + +export type PluginType = RecordProcessor + +export const makePlugin = ( + db: Database, + background: BackgroundQueue, +): PluginType => { + return new RecordProcessor(db, background, { + lexId, + insertFn, + findDuplicate, + deleteFn, + notifsForInsert, + notifsForDelete, + }) +} + +export default makePlugin diff --git a/packages/bsky/src/data-plane/server/routes/interactions.ts b/packages/bsky/src/data-plane/server/routes/interactions.ts index 73e46372a55..181bce53c23 100644 --- a/packages/bsky/src/data-plane/server/routes/interactions.ts +++ b/packages/bsky/src/data-plane/server/routes/interactions.ts @@ -2,6 +2,7 @@ import { keyBy } from '@atproto/common' import { ServiceImpl } from '@connectrpc/connect' import { Service } from '../../../proto/bsky_connect' import { Database } from '../db' +import { countAll } from '../db/util' export default (db: Database): Partial> => ({ async getInteractionCounts(req) { @@ -25,16 +26,31 @@ export default (db: Database): Partial> => ({ if (req.dids.length === 0) { return { followers: [], following: [], posts: [] } } + const { ref } = db.db.dynamic const res = await db.db .selectFrom('profile_agg') - .selectAll() .where('did', 'in', req.dids) + .selectAll('profile_agg') + .select([ + db.db + .selectFrom('feed_generator') + .whereRef('creator', '=', ref('profile_agg.did')) + .select(countAll.as('val')) + .as('feedGensCount'), + db.db + .selectFrom('list') + .whereRef('creator', '=', ref('profile_agg.did')) + .select(countAll.as('val')) + .as('listsCount'), + ]) .execute() const byDid = keyBy(res, 'did') return { followers: req.dids.map((uri) => byDid[uri]?.followersCount ?? 0), following: req.dids.map((uri) => byDid[uri]?.followsCount ?? 0), posts: req.dids.map((uri) => byDid[uri]?.postsCount ?? 0), + lists: req.dids.map((uri) => byDid[uri]?.listsCount ?? 0), + feeds: req.dids.map((uri) => byDid[uri]?.feedGensCount ?? 0), } }, }) diff --git a/packages/bsky/src/data-plane/server/routes/profile.ts b/packages/bsky/src/data-plane/server/routes/profile.ts index 20768ed89aa..dece31a99ee 100644 --- a/packages/bsky/src/data-plane/server/routes/profile.ts +++ b/packages/bsky/src/data-plane/server/routes/profile.ts @@ -3,6 +3,7 @@ import { Service } from '../../../proto/bsky_connect' import { keyBy } from '@atproto/common' import { getRecords } from './records' import { Database } from '../db' +import { sql } from 'kysely' export default (db: Database): Partial> => ({ async getActors(req) { @@ -13,8 +14,20 @@ export default (db: Database): Partial> => ({ const profileUris = dids.map( (did) => `at://${did}/app.bsky.actor.profile/self`, ) + const { ref } = db.db.dynamic const [handlesRes, profiles] = await Promise.all([ - db.db.selectFrom('actor').where('did', 'in', dids).selectAll().execute(), + db.db + .selectFrom('actor') + .where('did', 'in', dids) + .selectAll('actor') + .select([ + db.db + .selectFrom('labeler') + .whereRef('creator', '=', ref('actor.did')) + .select(sql`${true}`.as('val')) + .as('isLabeler'), + ]) + .execute(), getRecords(db)({ uris: profileUris }), ]) const byDid = keyBy(handlesRes, 'did') @@ -27,6 +40,7 @@ export default (db: Database): Partial> => ({ takenDown: !!row?.takedownRef, takedownRef: row?.takedownRef || undefined, tombstonedAt: undefined, // in current implementation, tombstoned actors are deleted + labeler: row?.isLabeler ?? false, } }) return { actors } diff --git a/packages/bsky/src/data-plane/server/routes/records.ts b/packages/bsky/src/data-plane/server/routes/records.ts index 47670a24412..fe5b51367e2 100644 --- a/packages/bsky/src/data-plane/server/routes/records.ts +++ b/packages/bsky/src/data-plane/server/routes/records.ts @@ -20,6 +20,7 @@ export default (db: Database): Partial> => ({ getProfileRecords: getRecords(db, ids.AppBskyActorProfile), getRepostRecords: getRecords(db, ids.AppBskyFeedRepost), getThreadGateRecords: getRecords(db, ids.AppBskyFeedThreadgate), + getLabelerRecords: getRecords(db, ids.AppBskyLabelerService), }) export const getRecords = diff --git a/packages/bsky/src/hydration/actor.ts b/packages/bsky/src/hydration/actor.ts index 3ed7f1b036e..44f8d4360e0 100644 --- a/packages/bsky/src/hydration/actor.ts +++ b/packages/bsky/src/hydration/actor.ts @@ -15,6 +15,7 @@ export type Actor = { profileTakedownRef?: string sortedAt?: Date takedownRef?: string + isLabeler: boolean } export type Actors = HydrationMap @@ -36,6 +37,8 @@ export type ProfileAgg = { followers: number follows: number posts: number + lists: number + feeds: number } export type ProfileAggs = HydrationMap @@ -100,6 +103,7 @@ export class ActorHydrator { profileTakedownRef: safeTakedownRef(profile), sortedAt: profile?.sortedAt?.toDate(), takedownRef: safeTakedownRef(actor), + isLabeler: actor.labeler ?? false, }) }, new HydrationMap()) } @@ -143,6 +147,8 @@ export class ActorHydrator { followers: counts.followers[i] ?? 0, follows: counts.following[i] ?? 0, posts: counts.posts[i] ?? 0, + lists: counts.lists[i] ?? 0, + feeds: counts.feeds[i] ?? 0, }) }, new HydrationMap()) } diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts index c79df963a7b..402e4985b5e 100644 --- a/packages/bsky/src/hydration/hydrator.ts +++ b/packages/bsky/src/hydration/hydrator.ts @@ -22,7 +22,13 @@ import { Lists, RelationshipPair, } from './graph' -import { LabelHydrator, Labels } from './label' +import { + LabelHydrator, + LabelerAggs, + LabelerViewerStates, + Labelers, + Labels, +} from './label' import { HydrationMap, RecordInfo, didFromUri, urisByCollection } from './util' import { FeedGenAggs, @@ -40,8 +46,13 @@ import { ItemRef, } from './feed' +export type HydrateCtx = { + labelers: string[] + viewer: string | null +} + export type HydrationState = { - viewer?: string | null + ctx?: HydrateCtx actors?: Actors profileViewers?: ProfileViewerStates profileAggs?: ProfileAggs @@ -61,6 +72,9 @@ export type HydrationState = { feedgens?: FeedGens feedgenViewers?: FeedGenViewerStates feedgenAggs?: FeedGenAggs + labelers?: Labelers + labelerViewers?: LabelerViewerStates + labelerAggs?: LabelerAggs } export type PostBlock = { embed: boolean; reply: boolean } @@ -76,14 +90,11 @@ export class Hydrator { graph: GraphHydrator label: LabelHydrator - constructor( - public dataplane: DataPlaneClient, - public opts?: { labelsFromIssuerDids?: string[] }, - ) { + constructor(public dataplane: DataPlaneClient) { this.actor = new ActorHydrator(dataplane) this.feed = new FeedHydrator(dataplane) this.graph = new GraphHydrator(dataplane) - this.label = new LabelHydrator(dataplane, opts) + this.label = new LabelHydrator(dataplane) } // app.bsky.actor.defs#profileView @@ -92,8 +103,10 @@ export class Hydrator { // Note: builds on the naive profile viewer hydrator and removes references to lists that have been deleted async hydrateProfileViewers( dids: string[], - viewer: string, + ctx: HydrateCtx, ): Promise { + const viewer = ctx.viewer + if (!viewer) return {} const profileViewers = await this.actor.getProfileViewerStatesNaive( dids, viewer, @@ -102,14 +115,14 @@ export class Hydrator { profileViewers?.forEach((item) => { listUris.push(...listUrisFromProfileViewer(item)) }) - const listState = await this.hydrateListsBasic(listUris, viewer) + const listState = await this.hydrateListsBasic(listUris, ctx) // if a list no longer exists or is not a mod list, then remove from viewer state profileViewers?.forEach((item) => { removeNonModListsFromProfileViewer(item, listState) }) return mergeStates(listState, { profileViewers, - viewer, + ctx, }) } @@ -118,18 +131,18 @@ export class Hydrator { // - list basic async hydrateProfiles( dids: string[], - viewer: string | null, + ctx: HydrateCtx, includeTakedowns = false, ): Promise { const [actors, labels, profileViewersState] = await Promise.all([ this.actor.getActors(dids, includeTakedowns), - this.label.getLabelsForSubjects(labelSubjectsForDid(dids)), - viewer ? this.hydrateProfileViewers(dids, viewer) : undefined, + this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers), + this.hydrateProfileViewers(dids, ctx), ]) return mergeStates(profileViewersState ?? {}, { actors, labels, - viewer, + ctx, }) } @@ -139,10 +152,10 @@ export class Hydrator { // - list basic async hydrateProfilesBasic( dids: string[], - viewer: string | null, + ctx: HydrateCtx, includeTakedowns = false, ): Promise { - return this.hydrateProfiles(dids, viewer, includeTakedowns) + return this.hydrateProfiles(dids, ctx, includeTakedowns) } // app.bsky.actor.defs#profileViewDetailed @@ -151,11 +164,11 @@ export class Hydrator { // - list basic async hydrateProfilesDetailed( dids: string[], - viewer: string | null, + ctx: HydrateCtx, includeTakedowns = false, ): Promise { const [state, profileAggs] = await Promise.all([ - this.hydrateProfiles(dids, viewer, includeTakedowns), + this.hydrateProfiles(dids, ctx, includeTakedowns), this.actor.getProfileAggregates(dids), ]) return { @@ -167,13 +180,10 @@ export class Hydrator { // app.bsky.graph.defs#listView // - list // - profile basic - async hydrateLists( - uris: string[], - viewer: string | null, - ): Promise { + async hydrateLists(uris: string[], ctx: HydrateCtx): Promise { const [listsState, profilesState] = await Promise.all([ - await this.hydrateListsBasic(uris, viewer), - await this.hydrateProfilesBasic(uris.map(didFromUri), viewer), + await this.hydrateListsBasic(uris, ctx), + await this.hydrateProfilesBasic(uris.map(didFromUri), ctx), ]) return mergeStates(listsState, profilesState) } @@ -182,13 +192,13 @@ export class Hydrator { // - list basic async hydrateListsBasic( uris: string[], - viewer: string | null, + ctx: HydrateCtx, ): Promise { const [lists, listViewers] = await Promise.all([ this.graph.getLists(uris), - viewer ? this.graph.getListViewerStates(uris, viewer) : undefined, + ctx.viewer ? this.graph.getListViewerStates(uris, ctx.viewer) : undefined, ]) - return { lists, listViewers, viewer } + return { lists, listViewers, ctx } } // app.bsky.graph.defs#listItemView @@ -197,7 +207,7 @@ export class Hydrator { // - list basic async hydrateListItems( uris: string[], - viewer: string | null, + ctx: HydrateCtx, ): Promise { const listItems = await this.graph.getListItems(uris) const dids: string[] = [] @@ -206,8 +216,8 @@ export class Hydrator { dids.push(item.record.subject) } }) - const profileState = await this.hydrateProfiles(dids, viewer) - return mergeStates(profileState, { listItems, viewer }) + const profileState = await this.hydrateProfiles(dids, ctx) + return mergeStates(profileState, { listItems, ctx }) } // app.bsky.feed.defs#postView @@ -220,9 +230,12 @@ export class Hydrator { // - feedgen // - profile // - list basic + // - mod service + // - profile + // - list basic async hydratePosts( refs: ItemRef[], - viewer: string | null, + ctx: HydrateCtx, includeTakedowns = false, state: HydrationState = {}, ): Promise { @@ -265,6 +278,10 @@ export class Hydrator { ...(urisLayer1ByCollection.get(ids.AppBskyFeedGenerator) ?? []), ...(urisLayer2ByCollection.get(ids.AppBskyFeedGenerator) ?? []), ] + const nestedLabelerDids = [ + ...(urisLayer1ByCollection.get(ids.AppBskyLabelerService) ?? []), + ...(urisLayer2ByCollection.get(ids.AppBskyLabelerService) ?? []), + ].map((uri) => new AtUri(uri).hostname) const posts = mergeManyMaps(postsLayer0, postsLayer1, postsLayer2) ?? postsLayer0 const allPostUris = [...posts.keys()] @@ -276,29 +293,33 @@ export class Hydrator { profileState, listState, feedGenState, + labelerState, ] = await Promise.all([ this.feed.getPostAggregates(refs), - viewer ? this.feed.getPostViewerStates(refs, viewer) : undefined, - this.label.getLabelsForSubjects(allPostUris), + ctx.viewer ? this.feed.getPostViewerStates(refs, ctx.viewer) : undefined, + this.label.getLabelsForSubjects(allPostUris, ctx.labelers), this.hydratePostBlocks(posts), - this.hydrateProfiles( - allPostUris.map(didFromUri), - viewer, - includeTakedowns, - ), - this.hydrateLists([...nestedListUris, ...gateListUris], viewer), - this.hydrateFeedGens(nestedFeedGenUris, viewer), + this.hydrateProfiles(allPostUris.map(didFromUri), ctx, includeTakedowns), + this.hydrateLists([...nestedListUris, ...gateListUris], ctx), + this.hydrateFeedGens(nestedFeedGenUris, ctx), + this.hydrateLabelers(nestedLabelerDids, ctx), ]) // combine all hydration state - return mergeManyStates(profileState, listState, feedGenState, { - posts, - postAggs, - postViewers, - postBlocks, - labels, - threadgates, - viewer, - }) + return mergeManyStates( + profileState, + listState, + feedGenState, + labelerState, + { + posts, + postAggs, + postViewers, + postBlocks, + labels, + threadgates, + ctx, + }, + ) } private async hydratePostBlocks(posts: Posts): Promise { @@ -354,7 +375,7 @@ export class Hydrator { // - ... async hydrateFeedItems( items: FeedItem[], - viewer: string | null, + ctx: HydrateCtx, includeTakedowns = false, ): Promise { const postUris = items.map((item) => item.post.uri) @@ -362,11 +383,7 @@ export class Hydrator { const [posts, reposts, repostProfileState] = await Promise.all([ this.feed.getPosts(postUris, includeTakedowns), this.feed.getReposts(repostUris, includeTakedowns), - this.hydrateProfiles( - repostUris.map(didFromUri), - viewer, - includeTakedowns, - ), + this.hydrateProfiles(repostUris.map(didFromUri), ctx, includeTakedowns), ]) const postAndReplyRefs: ItemRef[] = [] posts.forEach((post, uri) => { @@ -378,13 +395,13 @@ export class Hydrator { }) const postState = await this.hydratePosts( postAndReplyRefs, - viewer, + ctx, includeTakedowns, { posts }, ) return mergeManyStates(postState, repostProfileState, { reposts, - viewer, + ctx, }) } @@ -400,9 +417,9 @@ export class Hydrator { // - list basic async hydrateThreadPosts( refs: ItemRef[], - viewer: string | null, + ctx: HydrateCtx, ): Promise { - return this.hydratePosts(refs, viewer) + return this.hydratePosts(refs, ctx) } // app.bsky.feed.defs#generatorView @@ -411,20 +428,22 @@ export class Hydrator { // - list basic async hydrateFeedGens( uris: string[], // @TODO any way to get refs here? - viewer: string | null, + ctx: HydrateCtx, ): Promise { const [feedgens, feedgenAggs, feedgenViewers, profileState] = await Promise.all([ this.feed.getFeedGens(uris), this.feed.getFeedGenAggregates(uris.map((uri) => ({ uri }))), - viewer ? this.feed.getFeedGenViewerStates(uris, viewer) : undefined, - this.hydrateProfiles(uris.map(didFromUri), viewer), + ctx.viewer + ? this.feed.getFeedGenViewerStates(uris, ctx.viewer) + : undefined, + this.hydrateProfiles(uris.map(didFromUri), ctx), ]) return mergeStates(profileState, { feedgens, feedgenAggs, feedgenViewers, - viewer, + ctx, }) } @@ -432,27 +451,24 @@ export class Hydrator { // - like // - profile // - list basic - async hydrateLikes( - uris: string[], - viewer: string | null, - ): Promise { + async hydrateLikes(uris: string[], ctx: HydrateCtx): Promise { const [likes, profileState] = await Promise.all([ this.feed.getLikes(uris), - this.hydrateProfiles(uris.map(didFromUri), viewer), + this.hydrateProfiles(uris.map(didFromUri), ctx), ]) - return mergeStates(profileState, { likes, viewer }) + return mergeStates(profileState, { likes, ctx }) } // app.bsky.feed.getRepostedBy#repostedBy // - repost // - profile // - list basic - async hydrateReposts(uris: string[], viewer: string | null) { + async hydrateReposts(uris: string[], ctx: HydrateCtx) { const [reposts, profileState] = await Promise.all([ this.feed.getReposts(uris), - this.hydrateProfiles(uris.map(didFromUri), viewer), + this.hydrateProfiles(uris.map(didFromUri), ctx), ]) - return mergeStates(profileState, { reposts, viewer }) + return mergeStates(profileState, { reposts, ctx }) } // app.bsky.notification.listNotifications#notification @@ -461,7 +477,7 @@ export class Hydrator { // - list basic async hydrateNotifications( notifs: Notification[], - viewer: string | null, + ctx: HydrateCtx, ): Promise { const uris = notifs.map((notif) => notif.uri) const collections = urisByCollection(uris) @@ -475,8 +491,8 @@ export class Hydrator { this.feed.getLikes(likeUris), // reason: like this.feed.getReposts(repostUris), // reason: repost this.graph.getFollows(followUris), // reason: follow - this.label.getLabelsForSubjects(uris), - this.hydrateProfiles(uris.map(didFromUri), viewer), + this.label.getLabelsForSubjects(uris, ctx.labelers), + this.hydrateProfiles(uris.map(didFromUri), ctx), ]) return mergeStates(profileState, { posts, @@ -484,7 +500,7 @@ export class Hydrator { reposts, follows, labels, - viewer, + ctx, }) } @@ -512,6 +528,31 @@ export class Hydrator { return { follows, followBlocks } } + // app.bsky.labeler.def#labelerViewDetailed + // - labeler + // - profile + // - list basic + async hydrateLabelers( + dids: string[], + ctx: HydrateCtx, + ): Promise { + const [labelers, labelerAggs, labelerViewers, profileState] = + await Promise.all([ + this.label.getLabelers(dids), + this.label.getLabelerAggregates(dids), + ctx.viewer + ? this.label.getLabelerViewerStates(dids, ctx.viewer) + : undefined, + this.hydrateProfiles(dids.map(didFromUri), ctx), + ]) + return mergeStates(profileState, { + labelers, + labelerAggs, + labelerViewers, + ctx, + }) + } + // ad-hoc record hydration // in com.atproto.repo.getRecord async getRecord( @@ -560,6 +601,11 @@ export class Hydrator { (await this.feed.getFeedGens([uri], includeTakedowns)).get(uri) ?? undefined ) + } else if (collection === ids.AppBskyLabelerService) { + return ( + (await this.label.getLabelers([uri], includeTakedowns)).get(uri) ?? + undefined + ) } else if (collection === ids.AppBskyActorProfile) { const did = parsed.hostname const actor = (await this.actor.getActors([did], includeTakedowns)).get( @@ -681,11 +727,13 @@ export const mergeStates = ( stateB: HydrationState, ): HydrationState => { assert( - !stateA.viewer || !stateB.viewer || stateA.viewer === stateB.viewer, + !stateA.ctx?.viewer || + !stateB.ctx?.viewer || + stateA.ctx?.viewer === stateB.ctx?.viewer, 'incompatible viewers', ) return { - viewer: stateA.viewer ?? stateB.viewer, + ctx: stateA.ctx ?? stateB.ctx, actors: mergeMaps(stateA.actors, stateB.actors), profileAggs: mergeMaps(stateA.profileAggs, stateB.profileAggs), profileViewers: mergeMaps(stateA.profileViewers, stateB.profileViewers), @@ -705,6 +753,9 @@ export const mergeStates = ( feedgens: mergeMaps(stateA.feedgens, stateB.feedgens), feedgenAggs: mergeMaps(stateA.feedgenAggs, stateB.feedgenAggs), feedgenViewers: mergeMaps(stateA.feedgenViewers, stateB.feedgenViewers), + labelers: mergeMaps(stateA.labelers, stateB.labelers), + labelerAggs: mergeMaps(stateA.labelerAggs, stateB.labelerAggs), + labelerViewers: mergeMaps(stateA.labelerViewers, stateB.labelerViewers), } } diff --git a/packages/bsky/src/hydration/label.ts b/packages/bsky/src/hydration/label.ts index 352c9ed4059..458d46fcfc5 100644 --- a/packages/bsky/src/hydration/label.ts +++ b/packages/bsky/src/hydration/label.ts @@ -1,24 +1,42 @@ import { DataPlaneClient } from '../data-plane/client' import { Label } from '../lexicon/types/com/atproto/label/defs' -import { HydrationMap, parseJsonBytes } from './util' +import { Record as LabelerRecord } from '../lexicon/types/app/bsky/labeler/service' +import { + HydrationMap, + RecordInfo, + parseJsonBytes, + parseRecord, + parseString, +} from './util' +import { AtUri } from '@atproto/syntax' +import { ids } from '../lexicon/lexicons' export type { Label } from '../lexicon/types/com/atproto/label/defs' export type Labels = HydrationMap +export type LabelerAgg = { + likes: number +} + +export type LabelerAggs = HydrationMap + +export type Labeler = RecordInfo +export type Labelers = HydrationMap + +export type LabelerViewerState = { + like?: string +} + +export type LabelerViewerStates = HydrationMap + export class LabelHydrator { - constructor( - public dataplane: DataPlaneClient, - public opts?: { labelsFromIssuerDids?: string[] }, - ) {} + constructor(public dataplane: DataPlaneClient) {} async getLabelsForSubjects( subjects: string[], - issuers?: string[], + issuers: string[], ): Promise { - issuers = ([] as string[]) - .concat(issuers ?? []) - .concat(this.opts?.labelsFromIssuerDids ?? []) if (!subjects.length || !issuers.length) return new HydrationMap() const res = await this.dataplane.getLabels({ subjects, issuers }) return res.labels.reduce((acc, cur) => { @@ -33,4 +51,49 @@ export class LabelHydrator { return acc }, new HydrationMap()) } + + async getLabelers( + dids: string[], + includeTakedowns = false, + ): Promise { + const res = await this.dataplane.getLabelerRecords({ + uris: dids.map(labelerDidToUri), + }) + return dids.reduce((acc, did, i) => { + const record = parseRecord( + res.records[i], + includeTakedowns, + ) + return acc.set(did, record ?? null) + }, new HydrationMap()) + } + + async getLabelerViewerStates( + dids: string[], + viewer: string, + ): Promise { + const likes = await this.dataplane.getLikesByActorAndSubjects({ + actorDid: viewer, + refs: dids.map((did) => ({ uri: labelerDidToUri(did) })), + }) + return dids.reduce((acc, did, i) => { + return acc.set(did, { + like: parseString(likes.uris[i]), + }) + }, new HydrationMap()) + } + + async getLabelerAggregates(dids: string[]): Promise { + const refs = dids.map((did) => ({ uri: labelerDidToUri(did) })) + const counts = await this.dataplane.getInteractionCounts({ refs }) + return dids.reduce((acc, did, i) => { + return acc.set(did, { + likes: counts.likes[i] ?? 0, + }) + }, new HydrationMap()) + } +} + +const labelerDidToUri = (did: string): string => { + return AtUri.make(did, ids.AppBskyLabelerService, 'self').toString() } diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index b11fb42a453..db8d30cb7d2 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -77,9 +77,7 @@ export class BskyAppView { httpVersion: config.dataplaneHttpVersion, rejectUnauthorized: !config.dataplaneIgnoreBadTls, }) - const hydrator = new Hydrator(dataplane, { - labelsFromIssuerDids: config.labelsFromIssuerDids, - }) + const hydrator = new Hydrator(dataplane) const views = new Views(imgUriBuilder) const bsyncClient = createBsyncClient({ diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 4ace1ffbc86..1e0e766f1e0 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -129,6 +129,7 @@ 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 AppBskyLabelerGetServices from './types/app/bsky/labeler/getServices' 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' @@ -1205,6 +1206,7 @@ export class AppBskyNS { embed: AppBskyEmbedNS feed: AppBskyFeedNS graph: AppBskyGraphNS + labeler: AppBskyLabelerNS notification: AppBskyNotificationNS richtext: AppBskyRichtextNS unspecced: AppBskyUnspeccedNS @@ -1215,6 +1217,7 @@ export class AppBskyNS { this.embed = new AppBskyEmbedNS(server) this.feed = new AppBskyFeedNS(server) this.graph = new AppBskyGraphNS(server) + this.labeler = new AppBskyLabelerNS(server) this.notification = new AppBskyNotificationNS(server) this.richtext = new AppBskyRichtextNS(server) this.unspecced = new AppBskyUnspeccedNS(server) @@ -1660,6 +1663,25 @@ export class AppBskyGraphNS { } } +export class AppBskyLabelerNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + getServices( + cfg: ConfigOf< + AV, + AppBskyLabelerGetServices.Handler>, + AppBskyLabelerGetServices.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.labeler.getServices' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + export class AppBskyNotificationNS { _server: Server diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index d79d84cbb34..635e9e19e60 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -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', diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts index e219c846821..bf2d045f093 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts @@ -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) +} diff --git a/packages/bsky/src/lexicon/types/app/bsky/embed/record.ts b/packages/bsky/src/lexicon/types/app/bsky/embed/record.ts index dbe7f13152b..7dd4127ad5e 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/embed/record.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/embed/record.ts @@ -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 } diff --git a/packages/bsky/src/lexicon/types/app/bsky/labeler/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/labeler/defs.ts new file mode 100644 index 00000000000..e12c7203c57 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/labeler/defs.ts @@ -0,0 +1,93 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/bsky/src/lexicon/types/app/bsky/labeler/getServices.ts b/packages/bsky/src/lexicon/types/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..faeb30b4798 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/labeler/getServices.ts @@ -0,0 +1,51 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +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 type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/app/bsky/labeler/service.ts b/packages/bsky/src/lexicon/types/app/bsky/labeler/service.ts new file mode 100644 index 00000000000..a044b8699b6 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/labeler/service.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts index 7268650129a..66226677a5b 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/label/defs.ts @@ -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 & {}) diff --git a/packages/bsky/src/proto/bsky_connect.ts b/packages/bsky/src/proto/bsky_connect.ts index 00e2e5b9204..db3e318ef01 100644 --- a/packages/bsky/src/proto/bsky_connect.ts +++ b/packages/bsky/src/proto/bsky_connect.ts @@ -74,6 +74,8 @@ import { GetIdentityByHandleResponse, GetInteractionCountsRequest, GetInteractionCountsResponse, + GetLabelerRecordsRequest, + GetLabelerRecordsResponse, GetLabelsRequest, GetLabelsResponse, GetLatestRevRequest, @@ -272,6 +274,15 @@ export const Service = { O: GetThreadGateRecordsResponse, kind: MethodKind.Unary, }, + /** + * @generated from rpc bsky.Service.GetLabelerRecords + */ + getLabelerRecords: { + name: 'GetLabelerRecords', + I: GetLabelerRecordsRequest, + O: GetLabelerRecordsResponse, + kind: MethodKind.Unary, + }, /** * Follows * diff --git a/packages/bsky/src/proto/bsky_pb.ts b/packages/bsky/src/proto/bsky_pb.ts index 7c5ddcf1865..354b3ccdbaf 100644 --- a/packages/bsky/src/proto/bsky_pb.ts +++ b/packages/bsky/src/proto/bsky_pb.ts @@ -1474,6 +1474,122 @@ export class GetThreadGateRecordsResponse extends Message { + /** + * @generated from field: repeated string uris = 1; + */ + uris: string[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLabelerRecordsRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { + no: 1, + name: 'uris', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLabelerRecordsRequest { + return new GetLabelerRecordsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLabelerRecordsRequest { + return new GetLabelerRecordsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLabelerRecordsRequest { + return new GetLabelerRecordsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetLabelerRecordsRequest + | PlainMessage + | undefined, + b: + | GetLabelerRecordsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLabelerRecordsRequest, a, b) + } +} + +/** + * @generated from message bsky.GetLabelerRecordsResponse + */ +export class GetLabelerRecordsResponse extends Message { + /** + * @generated from field: repeated bsky.Record records = 1; + */ + records: Record[] = [] + + constructor(data?: PartialMessage) { + super() + proto3.util.initPartial(data, this) + } + + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'bsky.GetLabelerRecordsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: 'records', kind: 'message', T: Record, repeated: true }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): GetLabelerRecordsResponse { + return new GetLabelerRecordsResponse().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): GetLabelerRecordsResponse { + return new GetLabelerRecordsResponse().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): GetLabelerRecordsResponse { + return new GetLabelerRecordsResponse().fromJsonString(jsonString, options) + } + + static equals( + a: + | GetLabelerRecordsResponse + | PlainMessage + | undefined, + b: + | GetLabelerRecordsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(GetLabelerRecordsResponse, a, b) + } +} + /** * - Return follow uris where user A follows users B, C, D, … * - E.g. for viewer state on `getProfiles` @@ -2595,6 +2711,16 @@ export class GetCountsForUsersResponse extends Message) { super() proto3.util.initPartial(data, this) @@ -2631,6 +2757,20 @@ export class GetCountsForUsersResponse extends Message { */ tombstonedAt?: Timestamp + /** + * @generated from field: bool labeler = 7; + */ + labeler = false + constructor(data?: PartialMessage) { super() proto3.util.initPartial(data, this) @@ -3246,6 +3391,7 @@ export class ActorInfo extends Message { T: 9 /* ScalarType.STRING */, }, { no: 6, name: 'tombstoned_at', kind: 'message', T: Timestamp }, + { no: 7, name: 'labeler', kind: 'scalar', T: 8 /* ScalarType.BOOL */ }, ]) static fromBinary( diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts index 99450f0491c..4c573d6ace6 100644 --- a/packages/bsky/src/views/index.ts +++ b/packages/bsky/src/views/index.ts @@ -48,6 +48,10 @@ import { import { Label } from '../hydration/label' import { FeedItem, Post, Repost } from '../hydration/feed' import { RecordInfo } from '../hydration/util' +import { + LabelerView, + LabelerViewDetailed, +} from '../lexicon/types/app/bsky/labeler/defs' import { Notification } from '../proto/bsky_pb' export class Views { @@ -98,6 +102,11 @@ export class Views { followersCount: profileAggs?.followers, followsCount: profileAggs?.follows, postsCount: profileAggs?.posts, + associated: { + lists: profileAggs?.lists, + feedgens: profileAggs?.feeds, + labeler: actor?.isLabeler, + }, } } @@ -258,6 +267,54 @@ export class Views { }) } + labeler(did: string, state: HydrationState): LabelerView | undefined { + const labeler = state.labelers?.get(did) + if (!labeler) return + const creator = this.profile(did, state) + if (!creator) return + const viewer = state.labelerViewers?.get(did) + const aggs = state.labelerAggs?.get(did) + + const uri = AtUri.make(did, ids.AppBskyLabelerService, 'self').toString() + const labels = [ + ...(state.labels?.get(uri) ?? []), + ...this.selfLabels({ + uri, + cid: labeler.cid.toString(), + record: labeler.record, + }), + ] + + return { + uri, + cid: labeler.cid.toString(), + creator, + likeCount: aggs?.likes, + viewer: viewer + ? { + like: viewer.like, + } + : undefined, + indexedAt: labeler.sortedAt.toISOString(), + labels, + } + } + + labelerDetailed( + did: string, + state: HydrationState, + ): LabelerViewDetailed | undefined { + const baseView = this.labeler(did, state) + if (!baseView) return + const record = state.labelers?.get(did) + if (!record) return + + return { + ...baseView, + policies: record.record.policies, + } + } + // Feed // ------------ @@ -728,6 +785,11 @@ export class Views { if (!view) return this.embedNotFound(uri) view.$type = 'app.bsky.graph.defs#listView' return this.recordEmbedWrapper(view, withTypeTag) + } else if (parsedUri.collection === ids.AppBskyLabelerService) { + const view = this.labeler(parsedUri.hostname, state) + if (!view) return this.embedNotFound(uri) + view.$type = 'app.bsky.labeler.defs#labelerView' + return this.recordEmbedWrapper(view, withTypeTag) } return this.embedNotFound(uri) } @@ -771,7 +833,8 @@ export class Views { } const rootUriStr: string = post?.record.reply?.root.uri ?? uri const gate = state.threadgates?.get(postToGateUri(rootUriStr))?.record - if (!gate || !state.viewer) { + const viewer = state.ctx?.viewer + if (!gate || !viewer) { return undefined } const rootPost = state.posts?.get(rootUriStr)?.record @@ -780,7 +843,7 @@ export class Views { canReply, allowFollowing, allowListUris = [], - } = parseThreadGate(state.viewer, ownerDid, rootPost ?? null, gate) + } = parseThreadGate(viewer, ownerDid, rootPost ?? null, gate) if (canReply) { return false } diff --git a/packages/bsky/src/views/types.ts b/packages/bsky/src/views/types.ts index 8c5a3deb026..54f6fb40543 100644 --- a/packages/bsky/src/views/types.ts +++ b/packages/bsky/src/views/types.ts @@ -9,8 +9,6 @@ import { import { Main as RecordEmbed, View as RecordEmbedView, - ViewBlocked as EmbedBlocked, - ViewNotFound as EmbedNotFound, ViewRecord as PostEmbedView, } from '../lexicon/types/app/bsky/embed/record' import { @@ -24,6 +22,7 @@ import { PostView, } from '../lexicon/types/app/bsky/feed/defs' import { ListView } from '../lexicon/types/app/bsky/graph/defs' +import { LabelerView } from '../lexicon/types/app/bsky/labeler/defs' export type { Main as ImagesEmbed, @@ -69,4 +68,8 @@ export type EmbedView = export type MaybePostView = PostView | NotFoundPost | BlockedPost -export type RecordEmbedViewInternal = PostEmbedView | GeneratorView | ListView +export type RecordEmbedViewInternal = + | PostEmbedView + | GeneratorView + | ListView + | LabelerView diff --git a/packages/bsky/tests/_util.ts b/packages/bsky/tests/_util.ts index a86d47a04f6..48011be9b25 100644 --- a/packages/bsky/tests/_util.ts +++ b/packages/bsky/tests/_util.ts @@ -8,6 +8,11 @@ import { isThreadViewPost, } from '../src/lexicon/types/app/bsky/feed/defs' import { isViewRecord } from '../src/lexicon/types/app/bsky/embed/record' +import { + LabelerView, + isLabelerView, + isLabelerViewDetailed, +} from '../src/lexicon/types/app/bsky/labeler/defs' // Swap out identifiers and dates with stable // values for the purpose of snapshot testing @@ -190,3 +195,19 @@ export const stripViewerFromThread = (thread: T): T => { } return thread } + +// @NOTE mutates +export const stripViewerFromLabeler = ( + serviceUnknown: unknown, +): LabelerView => { + if ( + serviceUnknown?.['$type'] && + !isLabelerView(serviceUnknown) && + !isLabelerViewDetailed(serviceUnknown) + ) { + throw new Error('Expected mod service view') + } + const labeler = serviceUnknown as LabelerView + labeler.creator = stripViewer(labeler.creator) + return stripViewer(labeler) +} diff --git a/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap b/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap index e927f120505..c55f3afcefb 100644 --- a/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap +++ b/packages/bsky/tests/data-plane/__snapshots__/indexing.test.ts.snap @@ -3,6 +3,11 @@ exports[`indexing indexRepo updates indexes when records change. 1`] = ` Array [ Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "description": "freshening things up", "did": "user(0)", "followersCount": 2, @@ -620,6 +625,11 @@ Object { exports[`indexing indexes profiles. 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(0)", "displayName": "dan", "followersCount": 0, @@ -637,6 +647,11 @@ Object { exports[`indexing indexes profiles. 2`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(0)", "displayName": "danny", "followersCount": 0, @@ -654,6 +669,11 @@ Object { exports[`indexing indexes profiles. 3`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(0)", "followersCount": 0, "followsCount": 0, diff --git a/packages/bsky/tests/label-hydration.test.ts b/packages/bsky/tests/label-hydration.test.ts new file mode 100644 index 00000000000..ab672619f86 --- /dev/null +++ b/packages/bsky/tests/label-hydration.test.ts @@ -0,0 +1,100 @@ +import { AtpAgent } from '@atproto/api' +import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' + +describe('label hydration', () => { + let network: TestNetwork + let pdsAgent: AtpAgent + let sc: SeedClient + + let alice: string + let bob: string + let carol: string + let labelerDid: string + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_label_hydration', + }) + pdsAgent = network.pds.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + alice = sc.dids.alice + bob = sc.dids.bob + carol = sc.dids.carol + labelerDid = network.bsky.ctx.cfg.labelsFromIssuerDids[0] + await network.bsky.db.db + .insertInto('label') + .values([ + { + src: alice, + uri: carol, + cid: '', + val: 'spam', + neg: false, + cts: new Date().toISOString(), + }, + { + src: bob, + uri: carol, + cid: '', + val: 'impersonation', + neg: false, + cts: new Date().toISOString(), + }, + { + src: labelerDid, + uri: carol, + cid: '', + val: 'misleading', + neg: false, + cts: new Date().toISOString(), + }, + ]) + .execute() + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('hydrates labels based on a supplied labeler header', async () => { + const res = await pdsAgent.api.app.bsky.actor.getProfile( + { actor: carol }, + { headers: { ...sc.getHeaders(bob), 'atproto-labelers': alice } }, + ) + expect(res.data.labels?.length).toBe(1) + expect(res.data.labels?.[0].src).toBe(alice) + expect(res.data.labels?.[0].val).toBe('spam') + }) + + it('hydrates labels based on multiple a supplied labelers', async () => { + const res = await pdsAgent.api.app.bsky.actor.getProfile( + { actor: carol }, + { + headers: { + ...sc.getHeaders(bob), + 'atproto-labelers': `${alice},${bob}, ${labelerDid}`, + }, + }, + ) + expect(res.data.labels?.length).toBe(3) + expect(res.data.labels?.find((l) => l.src === alice)?.val).toEqual('spam') + expect(res.data.labels?.find((l) => l.src === bob)?.val).toEqual( + 'impersonation', + ) + expect(res.data.labels?.find((l) => l.src === labelerDid)?.val).toEqual( + 'misleading', + ) + }) + + it('defaults to service labels when no labeler header is provided', async () => { + const res = await pdsAgent.api.app.bsky.actor.getProfile( + { actor: carol }, + { headers: sc.getHeaders(bob) }, + ) + expect(res.data.labels?.length).toBe(1) + expect(res.data.labels?.[0].src).toBe(labelerDid) + expect(res.data.labels?.[0].val).toBe('misleading') + }) +}) diff --git a/packages/bsky/tests/views/__snapshots__/labeler-service.test.ts.snap b/packages/bsky/tests/views/__snapshots__/labeler-service.test.ts.snap new file mode 100644 index 00000000000..75e38cd05bb --- /dev/null +++ b/packages/bsky/tests/views/__snapshots__/labeler-service.test.ts.snap @@ -0,0 +1,160 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`labeler service views fetches labelers 1`] = ` +Object { + "views": Array [ + Object { + "$type": "app.bsky.labeler.defs#labelerView", + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "following": "record(1)", + "muted": false, + }, + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 1, + "uri": "record(0)", + "viewer": Object { + "like": "record(4)", + }, + }, + Object { + "$type": "app.bsky.labeler.defs#labelerView", + "cid": "cids(3)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "description": "hi im bob label_me", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 0, + "uri": "record(5)", + "viewer": Object {}, + }, + ], +} +`; + +exports[`labeler service views fetches labelers detailed 1`] = ` +Object { + "views": Array [ + Object { + "$type": "app.bsky.labeler.defs#labelerViewDetailed", + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "following": "record(1)", + "muted": false, + }, + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 1, + "policies": Object { + "labelValues": Array [ + "spam", + "!hide", + "scam", + "impersonation", + ], + }, + "uri": "record(0)", + "viewer": Object { + "like": "record(4)", + }, + }, + Object { + "$type": "app.bsky.labeler.defs#labelerViewDetailed", + "cid": "cids(3)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "description": "hi im bob label_me", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 0, + "policies": Object { + "labelValues": Array [ + "nudity", + "sexual", + "porn", + ], + }, + "uri": "record(5)", + "viewer": Object {}, + }, + ], +} +`; diff --git a/packages/bsky/tests/views/__snapshots__/profile.test.ts.snap b/packages/bsky/tests/views/__snapshots__/profile.test.ts.snap index 62b88f825e0..ea827cf9d8b 100644 --- a/packages/bsky/tests/views/__snapshots__/profile.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/profile.test.ts.snap @@ -3,6 +3,11 @@ exports[`pds profile views fetches multiple profiles 1`] = ` Array [ Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "its me!", "did": "user(0)", @@ -38,6 +43,11 @@ Array [ }, }, Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "hi im bob label_me", "did": "user(2)", @@ -54,6 +64,11 @@ Array [ }, }, Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(4)", "followersCount": 2, "followsCount": 1, @@ -67,6 +82,11 @@ Array [ }, }, Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(5)", "followersCount": 1, "followsCount": 1, @@ -84,6 +104,11 @@ Array [ exports[`pds profile views fetches other's profile, with a follow 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "its me!", "did": "user(0)", @@ -122,6 +147,11 @@ Object { exports[`pds profile views fetches other's profile, without a follow 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "did": "user(0)", "followersCount": 1, "followsCount": 1, @@ -138,6 +168,11 @@ Object { exports[`pds profile views fetches own profile 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "its me!", "did": "user(0)", @@ -174,6 +209,11 @@ Object { exports[`pds profile views presents avatars & banners 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "banner": "https://bsky.public.url/img/banner/plain/user(1)/cids(1)@jpeg", "description": "new descript", diff --git a/packages/bsky/tests/views/labeler-service.test.ts b/packages/bsky/tests/views/labeler-service.test.ts new file mode 100644 index 00000000000..3ef8d92a144 --- /dev/null +++ b/packages/bsky/tests/views/labeler-service.test.ts @@ -0,0 +1,156 @@ +import AtpAgent from '@atproto/api' +import { TestNetwork, SeedClient, basicSeed, RecordRef } from '@atproto/dev-env' +import { forSnapshot, stripViewerFromLabeler } from '../_util' +import { ids } from '../../src/lexicon/lexicons' + +describe('labeler service views', () => { + let network: TestNetwork + let agent: AtpAgent + let pdsAgent: AtpAgent + let sc: SeedClient + + // account dids, for convenience + let alice: string + let bob: string + + let aliceService: RecordRef + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_views_labeler_service', + }) + agent = network.bsky.getClient() + pdsAgent = network.pds.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + alice = sc.dids.alice + bob = sc.dids.bob + + const aliceRes = await pdsAgent.api.com.atproto.repo.createRecord( + { + repo: alice, + collection: ids.AppBskyLabelerService, + rkey: 'self', + record: { + policies: { + labelValues: ['spam', '!hide', 'scam', 'impersonation'], + }, + createdAt: new Date().toISOString(), + }, + }, + { headers: sc.getHeaders(alice), encoding: 'application/json' }, + ) + await pdsAgent.api.com.atproto.repo.createRecord( + { + repo: bob, + collection: ids.AppBskyLabelerService, + rkey: 'self', + record: { + policies: { + labelValues: ['nudity', 'sexual', 'porn'], + }, + createdAt: new Date().toISOString(), + }, + }, + { headers: sc.getHeaders(bob), encoding: 'application/json' }, + ) + + aliceService = new RecordRef(aliceRes.data.uri, aliceRes.data.cid) + + await sc.like(bob, aliceService) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('fetches labelers', async () => { + const view = await agent.api.app.bsky.labeler.getServices( + { dids: [alice, bob, 'did:example:missing'] }, + { headers: await network.serviceHeaders(bob) }, + ) + + expect(forSnapshot(view.data)).toMatchSnapshot() + }) + + it('fetches labelers detailed', async () => { + const view = await agent.api.app.bsky.labeler.getServices( + { dids: [alice, bob, 'did:example:missing'], detailed: true }, + { headers: await network.serviceHeaders(bob) }, + ) + + expect(forSnapshot(view.data)).toMatchSnapshot() + }) + + it('fetches labelers unauthed', async () => { + const { data: authed } = await agent.api.app.bsky.labeler.getServices( + { dids: [alice] }, + { headers: await network.serviceHeaders(bob) }, + ) + const { data: unauthed } = await agent.api.app.bsky.labeler.getServices({ + dids: [alice], + }) + expect(unauthed.views).toEqual(authed.views.map(stripViewerFromLabeler)) + }) + + it('fetches multiple labelers unauthed', async () => { + const { data: authed } = await agent.api.app.bsky.labeler.getServices( + { + dids: [alice, bob, 'did:example:missing'], + }, + { headers: await network.serviceHeaders(bob) }, + ) + const { data: unauthed } = await agent.api.app.bsky.labeler.getServices({ + dids: [alice, bob, 'did:example:missing'], + }) + expect(unauthed.views.length).toBeGreaterThan(0) + expect(unauthed.views).toEqual(authed.views.map(stripViewerFromLabeler)) + }) + + it('renders a post embed of a labeler', async () => { + const postRes = await pdsAgent.api.app.bsky.feed.post.create( + { repo: sc.dids.bob }, + { + text: 'check out this labeler', + embed: { + $type: 'app.bsky.embed.record', + record: aliceService.raw, + }, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(sc.dids.bob), + ) + + await network.processAll() + + const postViews = await agent.api.app.bsky.feed.getPosts({ + uris: [postRes.uri], + }) + const serviceViews = await agent.api.app.bsky.labeler.getServices({ + dids: [alice], + }) + expect(postViews.data.posts[0].embed?.record).toMatchObject( + serviceViews.data.views[0], + ) + }) + + it('blocked by labeler takedown', async () => { + await network.bsky.ctx.dataplane.takedownRecord({ + recordUri: aliceService.uriStr, + }) + + const res = await agent.api.app.bsky.labeler.getServices( + { dids: [alice, bob] }, + { headers: await network.serviceHeaders(bob) }, + ) + expect(res.data.views.length).toBe(1) + // @ts-ignore + expect(res.data.views[0].creator.did).toEqual(bob) + + // Cleanup + await network.bsky.ctx.dataplane.untakedownRecord({ + recordUri: aliceService.uriStr, + }) + }) +}) diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index 461ef4d07df..18a3f57b907 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -103,7 +103,9 @@ export class TestBsky { } getClient() { - return new AtpAgent({ service: this.url }) + const agent = new AtpAgent({ service: this.url }) + agent.configureLabelersHeader([]) + return agent } adminAuth(): string { diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index f115a1112ef..73319ad9c5b 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -325,6 +325,30 @@ export async function generateMockSetup(env: TestNetwork) { createdAt: date.next().value, }, ) + + await alice.agent.api.app.bsky.labeler.service.create( + { repo: alice.did, rkey: 'self' }, + { + displayName: 'alices labels', + description: 'Stopping spam and scams across the Atmosphere.', + avatar: avatarRes.data.blob, + policies: { + reportReasons: [ + 'com.atproto.moderation.defs#reasonSpam', + 'com.atproto.moderation.defs#reasonViolation', + 'com.atproto.moderation.defs#reasonMisleading', + ], + labelValues: ['spam', '!hide', 'scam', 'intolerant'], + }, + createdAt: date.next().value, + }, + ) + await createLabel(env.bsky.db, { + uri: bob.did, + cid: '', + val: 'spam', + src: alice.did, + }) } function ucfirst(str: string): string { @@ -333,7 +357,7 @@ function ucfirst(str: string): string { const createLabel = async ( db: Database, - opts: { uri: string; cid: string; val: string }, + opts: { uri: string; cid: string; val: string; src?: string }, ) => { await db.db .insertInto('label') @@ -343,7 +367,7 @@ const createLabel = async ( val: opts.val, cts: new Date().toISOString(), neg: false, - src: 'did:example:labeler', + src: opts.src ?? 'did:example:labeler', }) .execute() } diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 44b8a063fce..430f42e5a2a 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -68,7 +68,9 @@ export class TestPds { } getClient(): AtpAgent { - return new AtpAgent({ service: `http://localhost:${this.port}` }) + const agent = new AtpAgent({ service: this.url }) + agent.configureLabelersHeader([]) + return agent } adminAuth(role: 'admin' | 'moderator' | 'triage' = 'admin'): string { diff --git a/packages/ozone/src/lexicon/index.ts b/packages/ozone/src/lexicon/index.ts index 4ace1ffbc86..1e0e766f1e0 100644 --- a/packages/ozone/src/lexicon/index.ts +++ b/packages/ozone/src/lexicon/index.ts @@ -129,6 +129,7 @@ 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 AppBskyLabelerGetServices from './types/app/bsky/labeler/getServices' 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' @@ -1205,6 +1206,7 @@ export class AppBskyNS { embed: AppBskyEmbedNS feed: AppBskyFeedNS graph: AppBskyGraphNS + labeler: AppBskyLabelerNS notification: AppBskyNotificationNS richtext: AppBskyRichtextNS unspecced: AppBskyUnspeccedNS @@ -1215,6 +1217,7 @@ export class AppBskyNS { this.embed = new AppBskyEmbedNS(server) this.feed = new AppBskyFeedNS(server) this.graph = new AppBskyGraphNS(server) + this.labeler = new AppBskyLabelerNS(server) this.notification = new AppBskyNotificationNS(server) this.richtext = new AppBskyRichtextNS(server) this.unspecced = new AppBskyUnspeccedNS(server) @@ -1660,6 +1663,25 @@ export class AppBskyGraphNS { } } +export class AppBskyLabelerNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + getServices( + cfg: ConfigOf< + AV, + AppBskyLabelerGetServices.Handler>, + AppBskyLabelerGetServices.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.labeler.getServices' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + export class AppBskyNotificationNS { _server: Server diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index d79d84cbb34..635e9e19e60 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -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', diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts index e219c846821..bf2d045f093 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts @@ -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) +} diff --git a/packages/ozone/src/lexicon/types/app/bsky/embed/record.ts b/packages/ozone/src/lexicon/types/app/bsky/embed/record.ts index dbe7f13152b..7dd4127ad5e 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/embed/record.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/embed/record.ts @@ -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 } diff --git a/packages/ozone/src/lexicon/types/app/bsky/labeler/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/labeler/defs.ts new file mode 100644 index 00000000000..e12c7203c57 --- /dev/null +++ b/packages/ozone/src/lexicon/types/app/bsky/labeler/defs.ts @@ -0,0 +1,93 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/ozone/src/lexicon/types/app/bsky/labeler/getServices.ts b/packages/ozone/src/lexicon/types/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..faeb30b4798 --- /dev/null +++ b/packages/ozone/src/lexicon/types/app/bsky/labeler/getServices.ts @@ -0,0 +1,51 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +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 type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/ozone/src/lexicon/types/app/bsky/labeler/service.ts b/packages/ozone/src/lexicon/types/app/bsky/labeler/service.ts new file mode 100644 index 00000000000..a044b8699b6 --- /dev/null +++ b/packages/ozone/src/lexicon/types/app/bsky/labeler/service.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts index 7268650129a..66226677a5b 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/label/defs.ts @@ -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 & {}) diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index 74de7f3af6d..e2cbe47dcab 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -23,7 +23,9 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, METHOD_NSID, params, - requester ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), + requester + ? await ctx.appviewAuthHeaders(requester, req) + : authPassthru(req), ) if (!requester) { return res diff --git a/packages/pds/src/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/api/app/bsky/actor/getProfiles.ts index 3ab9338c7c5..d3802e4833a 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfiles.ts @@ -15,14 +15,14 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.actor.getProfiles({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did const res = await pipethrough( bskyAppView.url, METHOD_NSID, params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) return handleReadAfterWrite( ctx, diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index 6bfd65adf74..3a91306d4c5 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.actor.getSuggestions({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.actor.getSuggestions', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/actor/searchActors.ts b/packages/pds/src/api/app/bsky/actor/searchActors.ts index 53c97566818..bf8eff4fd42 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActors.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.actor.searchActors({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.actor.searchActors', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts index 51e778b24ee..c1f1acd8e2a 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.actor.searchActorsTypeahead', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts index 123bce1785b..63f15110b70 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getActorFeeds({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getActorFeeds', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index d8e2f839904..7eca9aa9862 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -23,7 +23,9 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, METHOD_NSID, params, - requester ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), + requester + ? await ctx.appviewAuthHeaders(requester, req) + : authPassthru(req), ) if (!requester) { diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index c90760bbfd9..7a9f2ea3e74 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -24,7 +24,9 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, METHOD_NSID, params, - requester ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), + requester + ? await ctx.appviewAuthHeaders(requester, req) + : authPassthru(req), ) if (!requester) { return res diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 89c84b18ba8..c141d2bbb5d 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -8,17 +8,18 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent || !bskyAppView) return server.app.bsky.feed.getFeed({ auth: ctx.authVerifier.access, - handler: async ({ req, params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did const { data: feed } = await appViewAgent.api.app.bsky.feed.getFeedGenerator( { feed: params.feed }, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) const serviceAuthHeaders = await ctx.serviceAuthHeaders( requester, feed.view.did, + req, ) // forward accept-language header to upstream services serviceAuthHeaders.headers['accept-language'] = diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts index f71ea74117f..ac235065e6c 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getFeedGenerator({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getFeedGenerator', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts index c07c3dac228..8a413b663b7 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getFeedGenerators({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getFeedGenerators', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getLikes.ts b/packages/pds/src/api/app/bsky/feed/getLikes.ts index 90a96681c85..5470c3c7cda 100644 --- a/packages/pds/src/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getLikes.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getLikes({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getLikes', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/api/app/bsky/feed/getListFeed.ts index 3447a721904..328dd1902eb 100644 --- a/packages/pds/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getListFeed.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getListFeed({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getListFeed', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index da09523875b..720a6b2796b 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -49,7 +49,7 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, METHOD_NSID, params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) return await handleReadAfterWrite( @@ -206,7 +206,7 @@ const readAfterWriteNotFound = async ( assert(ctx.appViewAgent) const parentsRes = await ctx.appViewAgent.api.app.bsky.feed.getPostThread( { uri: highestParent, parentHeight: params.parentHeight, depth: 0 }, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, null), ) thread.parent = parentsRes.data.thread } catch (err) { diff --git a/packages/pds/src/api/app/bsky/feed/getPosts.ts b/packages/pds/src/api/app/bsky/feed/getPosts.ts index 89d0d08587d..ec8ff1b35e3 100644 --- a/packages/pds/src/api/app/bsky/feed/getPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/getPosts.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getPosts({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getPosts', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts index 971d150824c..e3cbe112e24 100644 --- a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getRepostedBy({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getRepostedBy', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts index 6da81787533..6e83689d5ef 100644 --- a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.getSuggestedFeeds', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/feed/getTimeline.ts b/packages/pds/src/api/app/bsky/feed/getTimeline.ts index 90fc5bac42f..810f6931daa 100644 --- a/packages/pds/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/pds/src/api/app/bsky/feed/getTimeline.ts @@ -15,13 +15,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.getTimeline({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did const res = await pipethrough( bskyAppView.url, METHOD_NSID, params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) return await handleReadAfterWrite( ctx, diff --git a/packages/pds/src/api/app/bsky/feed/searchPosts.ts b/packages/pds/src/api/app/bsky/feed/searchPosts.ts index 7cc09c864e5..263729e6de6 100644 --- a/packages/pds/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/searchPosts.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.feed.searchPosts({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.feed.searchPosts', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/api/app/bsky/graph/getBlocks.ts index 1b29f9b62d2..63b8294ac8b 100644 --- a/packages/pds/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getBlocks.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getBlocks({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getBlocks', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index 0a158f2bbe5..28e74f41683 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -15,7 +15,9 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, 'app.bsky.graph.getFollowers', params, - requester ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), + requester + ? await ctx.appviewAuthHeaders(requester, req) + : authPassthru(req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index 6802acda888..d50b4bbe6e1 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -15,7 +15,9 @@ export default function (server: Server, ctx: AppContext) { bskyAppView.url, 'app.bsky.graph.getFollows', params, - requester ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), + requester + ? await ctx.appviewAuthHeaders(requester, req) + : authPassthru(req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getList.ts b/packages/pds/src/api/app/bsky/graph/getList.ts index 6ef1dbf7ee0..3da09accb89 100644 --- a/packages/pds/src/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/api/app/bsky/graph/getList.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getList({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getList', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts index d9aed6e7cd6..8ee29641b51 100644 --- a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getListBlocks({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getListBlocks', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/api/app/bsky/graph/getListMutes.ts index 575c09d5b1a..1c7519065b8 100644 --- a/packages/pds/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getListMutes.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getListMutes({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getListMutes', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getLists.ts b/packages/pds/src/api/app/bsky/graph/getLists.ts index c824c9cdb4b..297deb6ea97 100644 --- a/packages/pds/src/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/api/app/bsky/graph/getLists.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getLists({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getLists', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getMutes.ts b/packages/pds/src/api/app/bsky/graph/getMutes.ts index d422237dd0f..f741c6d8cd1 100644 --- a/packages/pds/src/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getMutes.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getMutes({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getMutes', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index dfe453be8f6..4d3866bf121 100644 --- a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.graph.getSuggestedFollowsByActor({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.graph.getSuggestedFollowsByActor', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/graph/muteActor.ts b/packages/pds/src/api/app/bsky/graph/muteActor.ts index c88a05b9eaf..8492c1715a5 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActor.ts @@ -6,11 +6,11 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.graph.muteActor({ auth: ctx.authVerifier.access, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { const requester = auth.credentials.did await appViewAgent.api.app.bsky.graph.muteActor(input.body, { - ...(await ctx.appviewAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester, req)), encoding: 'application/json', }) }, diff --git a/packages/pds/src/api/app/bsky/graph/muteActorList.ts b/packages/pds/src/api/app/bsky/graph/muteActorList.ts index 74c2357d3d9..09b5cf913e2 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActorList.ts @@ -6,11 +6,11 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.graph.muteActorList({ auth: ctx.authVerifier.access, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { const requester = auth.credentials.did await appViewAgent.api.app.bsky.graph.muteActorList(input.body, { - ...(await ctx.appviewAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester, req)), encoding: 'application/json', }) }, diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts index e73c5d08e5a..baccec3e4da 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts @@ -6,11 +6,11 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.graph.unmuteActor({ auth: ctx.authVerifier.access, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { const requester = auth.credentials.did await appViewAgent.api.app.bsky.graph.unmuteActor(input.body, { - ...(await ctx.appviewAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester, req)), encoding: 'application/json', }) }, diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts index e36afeaf0a3..eaaf081c81c 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts @@ -6,11 +6,11 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.graph.unmuteActorList({ auth: ctx.authVerifier.access, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { const requester = auth.credentials.did await appViewAgent.api.app.bsky.graph.unmuteActorList(input.body, { - ...(await ctx.appviewAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester, req)), encoding: 'application/json', }) }, diff --git a/packages/pds/src/api/app/bsky/index.ts b/packages/pds/src/api/app/bsky/index.ts index 8f38a52683f..9ba333bbaa6 100644 --- a/packages/pds/src/api/app/bsky/index.ts +++ b/packages/pds/src/api/app/bsky/index.ts @@ -3,6 +3,7 @@ import AppContext from '../../../context' import actor from './actor' import feed from './feed' import graph from './graph' +import labeler from './labeler' import notification from './notification' import unspecced from './unspecced' @@ -10,6 +11,7 @@ export default function (server: Server, ctx: AppContext) { actor(server, ctx) feed(server, ctx) graph(server, ctx) + labeler(server, ctx) notification(server, ctx) unspecced(server, ctx) } diff --git a/packages/pds/src/api/app/bsky/labeler/getServices.ts b/packages/pds/src/api/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..d1cf93e7f87 --- /dev/null +++ b/packages/pds/src/api/app/bsky/labeler/getServices.ts @@ -0,0 +1,21 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + const { appViewAgent } = ctx + if (!appViewAgent) return + server.app.bsky.labeler.getServices({ + auth: ctx.authVerifier.access, + handler: async ({ params, auth, req }) => { + const requester = auth.credentials.did + const res = await appViewAgent.api.app.bsky.labeler.getServices( + params, + await ctx.appviewAuthHeaders(requester, req), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/pds/src/api/app/bsky/labeler/index.ts b/packages/pds/src/api/app/bsky/labeler/index.ts new file mode 100644 index 00000000000..0b00c7196a0 --- /dev/null +++ b/packages/pds/src/api/app/bsky/labeler/index.ts @@ -0,0 +1,7 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import getServices from './getServices' + +export default function (server: Server, ctx: AppContext) { + getServices(server, ctx) +} diff --git a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts index d6b8a235ba3..8483f8a9dc4 100644 --- a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.notification.getUnreadCount({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.notification.getUnreadCount', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/api/app/bsky/notification/listNotifications.ts index 005473eb6f4..80bb770eeec 100644 --- a/packages/pds/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/api/app/bsky/notification/listNotifications.ts @@ -7,13 +7,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.notification.listNotifications({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.notification.listNotifications', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index ec6084c41aa..b06dcee3f55 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -10,13 +10,13 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.notification.registerPush({ auth: ctx.authVerifier.accessDeactived, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { const { serviceDid } = input.body const { credentials: { did }, } = auth - const authHeaders = await ctx.serviceAuthHeaders(did, serviceDid) + const authHeaders = await ctx.serviceAuthHeaders(did, serviceDid, req) if (ctx.cfg.bskyAppView?.did === serviceDid) { await appViewAgent.api.app.bsky.notification.registerPush(input.body, { diff --git a/packages/pds/src/api/app/bsky/notification/updateSeen.ts b/packages/pds/src/api/app/bsky/notification/updateSeen.ts index 18a0ea3fa11..5de6d9481a4 100644 --- a/packages/pds/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/pds/src/api/app/bsky/notification/updateSeen.ts @@ -6,11 +6,11 @@ export default function (server: Server, ctx: AppContext) { if (!appViewAgent) return server.app.bsky.notification.updateSeen({ auth: ctx.authVerifier.access, - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { const requester = auth.credentials.did await appViewAgent.api.app.bsky.notification.updateSeen(input.body, { - ...(await ctx.appviewAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester, req)), encoding: 'application/json', }) }, diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index da7be6fb649..ff810021dd6 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -8,13 +8,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.unspecced.getPopularFeedGenerators({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.unspecced.getPopularFeedGenerators', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts index 68e84985441..591afbee592 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts @@ -8,13 +8,13 @@ export default function (server: Server, ctx: AppContext) { if (!bskyAppView) return server.app.bsky.unspecced.getTaggedSuggestions({ auth: ctx.authVerifier.access, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { const requester = auth.credentials.did return pipethrough( bskyAppView.url, 'app.bsky.unspecced.getTaggedSuggestions', params, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, req), ) }, }) diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index 6e30159c204..028a0b55079 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -7,7 +7,7 @@ import { resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ auth: ctx.authVerifier.roleOrModService, - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError('Insufficient privileges') } @@ -34,6 +34,7 @@ export default function (server: Server, ctx: AppContext) { ...(await ctx.serviceAuthHeaders( recipientDid, ctx.cfg.entryway?.did, + req, )), }), ) diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 4f852af1497..dcffec1bde4 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -1,4 +1,5 @@ import assert from 'node:assert' +import express from 'express' import * as nodemailer from 'nodemailer' import { Redis } from 'ioredis' import * as plc from '@did-plc/lib' @@ -250,28 +251,37 @@ export class AppContext { }) } - async appviewAuthHeaders(did: string) { + async appviewAuthHeaders(did: string, req: express.Request | null) { assert(this.cfg.bskyAppView) - return this.serviceAuthHeaders(did, this.cfg.bskyAppView.did) + return this.serviceAuthHeaders(did, this.cfg.bskyAppView.did, req) } async moderationAuthHeaders(did: string) { assert(this.cfg.modService) - return this.serviceAuthHeaders(did, this.cfg.modService.did) + return this.serviceAuthHeaders(did, this.cfg.modService.did, null) } async reportingAuthHeaders(did: string) { assert(this.cfg.reportService) - return this.serviceAuthHeaders(did, this.cfg.reportService.did) + return this.serviceAuthHeaders(did, this.cfg.reportService.did, null) } - async serviceAuthHeaders(did: string, aud: string) { + async serviceAuthHeaders( + did: string, + aud: string, + req: express.Request | null, + ) { const keypair = await this.actorStore.keypair(did) - return createServiceAuthHeaders({ + const authHeaders = await createServiceAuthHeaders({ iss: did, aud, keypair, }) + const labelerHeader = req?.header('atproto-labelers') + if (labelerHeader) { + authHeaders.headers['atproto-labelers'] = labelerHeader + } + return authHeaders } } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 4ace1ffbc86..1e0e766f1e0 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -129,6 +129,7 @@ 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 AppBskyLabelerGetServices from './types/app/bsky/labeler/getServices' 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' @@ -1205,6 +1206,7 @@ export class AppBskyNS { embed: AppBskyEmbedNS feed: AppBskyFeedNS graph: AppBskyGraphNS + labeler: AppBskyLabelerNS notification: AppBskyNotificationNS richtext: AppBskyRichtextNS unspecced: AppBskyUnspeccedNS @@ -1215,6 +1217,7 @@ export class AppBskyNS { this.embed = new AppBskyEmbedNS(server) this.feed = new AppBskyFeedNS(server) this.graph = new AppBskyGraphNS(server) + this.labeler = new AppBskyLabelerNS(server) this.notification = new AppBskyNotificationNS(server) this.richtext = new AppBskyRichtextNS(server) this.unspecced = new AppBskyUnspeccedNS(server) @@ -1660,6 +1663,25 @@ export class AppBskyGraphNS { } } +export class AppBskyLabelerNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + getServices( + cfg: ConfigOf< + AV, + AppBskyLabelerGetServices.Handler>, + AppBskyLabelerGetServices.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.labeler.getServices' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + export class AppBskyNotificationNS { _server: Server diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index d79d84cbb34..635e9e19e60 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -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', diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts index e219c846821..bf2d045f093 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts @@ -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) +} diff --git a/packages/pds/src/lexicon/types/app/bsky/embed/record.ts b/packages/pds/src/lexicon/types/app/bsky/embed/record.ts index dbe7f13152b..7dd4127ad5e 100644 --- a/packages/pds/src/lexicon/types/app/bsky/embed/record.ts +++ b/packages/pds/src/lexicon/types/app/bsky/embed/record.ts @@ -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 } diff --git a/packages/pds/src/lexicon/types/app/bsky/labeler/defs.ts b/packages/pds/src/lexicon/types/app/bsky/labeler/defs.ts new file mode 100644 index 00000000000..e12c7203c57 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/labeler/defs.ts @@ -0,0 +1,93 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/pds/src/lexicon/types/app/bsky/labeler/getServices.ts b/packages/pds/src/lexicon/types/app/bsky/labeler/getServices.ts new file mode 100644 index 00000000000..faeb30b4798 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/labeler/getServices.ts @@ -0,0 +1,51 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +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 type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/app/bsky/labeler/service.ts b/packages/pds/src/lexicon/types/app/bsky/labeler/service.ts new file mode 100644 index 00000000000..a044b8699b6 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/labeler/service.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +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) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/label/defs.ts b/packages/pds/src/lexicon/types/com/atproto/label/defs.ts index 7268650129a..66226677a5b 100644 --- a/packages/pds/src/lexicon/types/com/atproto/label/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/label/defs.ts @@ -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 & {}) diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 22d39d28164..5daad7572b3 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -2,6 +2,11 @@ exports[`proxies view requests actor.getProfile 1`] = ` Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "hi im bob label_me", "did": "user(0)", @@ -25,6 +30,11 @@ exports[`proxies view requests actor.getProfiles 1`] = ` Object { "profiles": Array [ Object { + "associated": Object { + "feedgens": 1, + "labeler": false, + "lists": 1, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "its me!", "did": "user(0)", @@ -58,6 +68,11 @@ Object { }, }, Object { + "associated": Object { + "feedgens": 0, + "labeler": false, + "lists": 0, + }, "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "hi im bob label_me", "did": "user(2)",