diff --git a/.eslintrc b/.eslintrc index 11c6e76318a..650df576b78 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,16 +13,7 @@ "plugin:prettier/recommended", "prettier" ], - "ignorePatterns": [ - "dist", - "node_modules", - "jest.config.base.js", - "jest.bench.config.js", - "jest.config.js", - "babel.config.js", - "build.js", - "update-pkg.js" - ], + "ignorePatterns": ["dist", "node_modules"], "rules": { "no-var": "error", "prefer-const": "warn", @@ -36,5 +27,21 @@ "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off" - } + }, + "overrides": [ + { + "files": ["jest.config.js"], + "env": { "commonjs": true } + }, + { + "files": ["jest.setup.js"], + "env": { "jest": true } + }, + { + "files": "*.js", + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] } diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index f534e015ea5..36b1aa23cb3 100644 --- a/.github/workflows/build-and-push-bsky-aws.yaml +++ b/.github/workflows/build-and-push-bsky-aws.yaml @@ -3,7 +3,6 @@ on: push: branches: - main - - appview-v1-courier env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c84ab0d94cb..48581feaa63 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -27,6 +27,7 @@ jobs: node-version: 18 cache: 'pnpm' - run: pnpm i --frozen-lockfile + - run: pnpm build - run: pnpm verify - name: Publish id: changesets diff --git a/.github/workflows/repo.yaml b/.github/workflows/repo.yaml index 8380fff8a63..3b46597f514 100644 --- a/.github/workflows/repo.yaml +++ b/.github/workflows/repo.yaml @@ -24,8 +24,14 @@ jobs: cache: 'pnpm' - run: pnpm i --frozen-lockfile - run: pnpm build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: packages/*/dist + retention-days: 1 test: name: Test + needs: build strategy: matrix: shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] @@ -40,9 +46,14 @@ jobs: node-version: 18 cache: 'pnpm' - run: pnpm i --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: dist + path: packages - run: pnpm test:withFlags --maxWorkers=1 --shard=${{ matrix.shard }} --passWithNoTests verify: name: Verify + needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -54,4 +65,8 @@ jobs: node-version: 18 cache: 'pnpm' - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: dist + path: packages - run: pnpm verify diff --git a/.prettierignore b/.prettierignore index 6340cc359c2..7bb137f13ea 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ pnpm-lock.yaml .pnpm* .changeset *.d.ts +packages/bsky/src/data-plane/gen \ No newline at end of file diff --git a/README.md b/README.md index a54fa91fe5f..96b7ee350f2 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ make help ## About AT Protocol -The Authenticated Transfer Protocol ("ATP" or "atproto") is a decentralized social media protocol, developed by [Bluesky PBC](https://blueskyweb.xyz). Learn more at: +The Authenticated Transfer Protocol ("ATP" or "atproto") is a decentralized social media protocol, developed by [Bluesky PBC](https://bsky.social). Learn more at: - [Overview and Guides](https://atproto.com/guides/overview) 👈 Best starting point - [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions - [Protocol Specifications](https://atproto.com/specs/atp) -- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol) +- [Blogpost on self-authenticating data structures](https://bsky.social/about/blog/3-6-2022-a-self-authenticating-social-protocol) The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`. diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index ee58f35df11..00000000000 --- a/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [['@babel/preset-env']], -} diff --git a/interop-test-files/syntax/aturi_syntax_valid.txt b/interop-test-files/syntax/aturi_syntax_valid.txt index 2552a964ce0..fd4523d0de9 100644 --- a/interop-test-files/syntax/aturi_syntax_valid.txt +++ b/interop-test-files/syntax/aturi_syntax_valid.txt @@ -24,3 +24,11 @@ at://did:plc:asdf123/com.atproto.feed.post/a at://did:plc:asdf123/com.atproto.feed.post/asdf-123 at://did:abc:123 at://did:abc:123/io.nsid.someFunc/record-key + +at://did:abc:123/io.nsid.someFunc/self. +at://did:abc:123/io.nsid.someFunc/lang: +at://did:abc:123/io.nsid.someFunc/: +at://did:abc:123/io.nsid.someFunc/- +at://did:abc:123/io.nsid.someFunc/_ +at://did:abc:123/io.nsid.someFunc/~ +at://did:abc:123/io.nsid.someFunc/... diff --git a/interop-test-files/syntax/recordkey_syntax_invalid.txt b/interop-test-files/syntax/recordkey_syntax_invalid.txt index 1da3d1e7dbc..52106d873a0 100644 --- a/interop-test-files/syntax/recordkey_syntax_invalid.txt +++ b/interop-test-files/syntax/recordkey_syntax_invalid.txt @@ -1,5 +1,4 @@ # specs -literal:self alpha/beta . .. @@ -10,5 +9,7 @@ any+space number[3] number(3) "quote" -pre:fix dHJ1ZQ== + +# too long: 'o'.repeat(513) +ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo diff --git a/interop-test-files/syntax/recordkey_syntax_valid.txt b/interop-test-files/syntax/recordkey_syntax_valid.txt index 8d77d04d2b7..92e8b7e31c9 100644 --- a/interop-test-files/syntax/recordkey_syntax_valid.txt +++ b/interop-test-files/syntax/recordkey_syntax_valid.txt @@ -3,6 +3,19 @@ self example.com ~1.2-3_ dHJ1ZQ +_ +literal:self +pre:fix + +# more corner-cases +: +- +_ +~ +... +self. +lang: +:lang # very long: 'o'.repeat(512) oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo diff --git a/jest.config.base.js b/jest.config.base.js deleted file mode 100644 index ee4e2799057..00000000000 --- a/jest.config.base.js +++ /dev/null @@ -1,20 +0,0 @@ -// Jest doesn't like ES modules, so we need to transpile them -// For each one, add them to this list, add them to -// "workspaces.nohoist" in the root package.json, and -// make sure that a babel.config.js is in the package root -const esModules = ['get-port', 'node-fetch'].join('|') - -// jestconfig.base.js -module.exports = { - roots: ['/src', '/tests'], - transform: { - '^.+\\.(t|j)s?$': '@swc/jest', - '^.+\\.hbs$': require.resolve('handlebars-jest'), - }, - transformIgnorePatterns: [`/node_modules/(?!${esModules})`], - testRegex: '(/tests/.*.(test|spec)).(jsx?|tsx?)$', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - setupFiles: ['/../../test-setup.ts'], - verbose: true, - testTimeout: 60000, -} diff --git a/jest.config.js b/jest.config.js index 2366503d1d3..d0126bea35b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,4 @@ -// jest.config.js -const base = require('./jest.config.base.js') - +/** @type {import('jest').Config} */ module.exports = { - ...base, projects: ['/packages/*/jest.config.js'], } diff --git a/test-setup.ts b/jest.setup.ts similarity index 100% rename from test-setup.ts rename to jest.setup.ts diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index 9f8e2ea97c8..1ad8493002e 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -1,7 +1,6 @@ { "lexicon": 1, "id": "app.bsky.actor.defs", - "description": "A reference to an actor in the network.", "defs": { "profileViewBasic": { "type": "object", @@ -15,6 +14,10 @@ "maxLength": 640 }, "avatar": { "type": "string" }, + "associated": { + "type": "ref", + "ref": "#profileAssociated" + }, "viewer": { "type": "ref", "ref": "#viewerState" }, "labels": { "type": "array", @@ -39,6 +42,10 @@ "maxLength": 2560 }, "avatar": { "type": "string" }, + "associated": { + "type": "ref", + "ref": "#profileAssociated" + }, "indexedAt": { "type": "string", "format": "datetime" }, "viewer": { "type": "ref", "ref": "#viewerState" }, "labels": { @@ -68,6 +75,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": { @@ -76,8 +87,17 @@ } } }, + "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.", "properties": { "muted": { "type": "boolean" }, "mutedByList": { @@ -105,7 +125,9 @@ "#personalDetailsPref", "#feedViewPref", "#threadViewPref", - "#interestsPref" + "#interestsPref", + "#mutedWordsPref", + "#hiddenPostsPref" ] } }, @@ -120,10 +142,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"] } } }, @@ -144,6 +171,9 @@ "type": "string", "format": "at-uri" } + }, + "timelineIndex": { + "type": "integer" } } }, @@ -171,7 +201,8 @@ }, "hideRepliesByUnfollowed": { "type": "boolean", - "description": "Hide replies in the feed if they are not by followed users." + "description": "Hide replies in the feed if they are not by followed users.", + "default": true }, "hideRepliesByLikeCount": { "type": "integer", @@ -212,6 +243,81 @@ "description": "A list of tags which describe the account owner's interests gathered during onboarding." } } + }, + "mutedWordTarget": { + "type": "string", + "knownValues": ["content", "tag"], + "maxLength": 640, + "maxGraphemes": 64 + }, + "mutedWord": { + "type": "object", + "description": "A word that the account owner has muted.", + "required": ["value", "targets"], + "properties": { + "value": { + "type": "string", + "description": "The muted word itself.", + "maxLength": 10000, + "maxGraphemes": 1000 + }, + "targets": { + "type": "array", + "description": "The intended targets of the muted word.", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#mutedWordTarget" + } + } + } + }, + "mutedWordsPref": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#mutedWord" + }, + "description": "A list of words the account owner has muted." + } + } + }, + "hiddenPostsPref": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "string", "format": "at-uri" }, + "description": "A list of URIs of posts the account owner has hidden." + } + } + }, + "labelersPref": { + "type": "object", + "required": ["labelers"], + "properties": { + "labelers": { + "type": "array", + "items": { + "type": "ref", + "ref": "#labelerPrefItem" + } + } + } + }, + "labelerPrefItem": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } } } } diff --git a/lexicons/app/bsky/actor/getPreferences.json b/lexicons/app/bsky/actor/getPreferences.json index cbd6b60bd6a..e6356a86f47 100644 --- a/lexicons/app/bsky/actor/getPreferences.json +++ b/lexicons/app/bsky/actor/getPreferences.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get private preferences attached to the account.", + "description": "Get private preferences attached to the current account. Expected use is synchronization between multiple devices, and import/export during account migration. Requires auth.", "parameters": { "type": "params", "properties": {} diff --git a/lexicons/app/bsky/actor/getProfile.json b/lexicons/app/bsky/actor/getProfile.json index 1bb2ad2fea1..15b0fcc2ec0 100644 --- a/lexicons/app/bsky/actor/getProfile.json +++ b/lexicons/app/bsky/actor/getProfile.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get detailed profile view of an actor.", + "description": "Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth.", "parameters": { "type": "params", "required": ["actor"], "properties": { - "actor": { "type": "string", "format": "at-identifier" } + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Handle or DID of account to fetch profile of." + } } }, "output": { diff --git a/lexicons/app/bsky/actor/getSuggestions.json b/lexicons/app/bsky/actor/getSuggestions.json index 74465dfdf2e..2004ae6f23e 100644 --- a/lexicons/app/bsky/actor/getSuggestions.json +++ b/lexicons/app/bsky/actor/getSuggestions.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of suggested actors, used for discovery.", + "description": "Get a list of suggested actors. Expected use is discovery of accounts to follow during new account onboarding.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/actor/profile.json b/lexicons/app/bsky/actor/profile.json index e1b7c6a2b96..feb083d500a 100644 --- a/lexicons/app/bsky/actor/profile.json +++ b/lexicons/app/bsky/actor/profile.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a profile.", + "description": "A declaration of a Bluesky account profile.", "key": "literal:self", "record": { "type": "object", @@ -16,21 +16,25 @@ }, "description": { "type": "string", + "description": "Free-form profile description text.", "maxGraphemes": 256, "maxLength": 2560 }, "avatar": { "type": "blob", + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000 }, "banner": { "type": "blob", + "description": "Larger horizontal image to display behind profile view.", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000 }, "labels": { "type": "union", + "description": "Self-label values, specific to the Bluesky application, on the overall account.", "refs": ["com.atproto.label.defs#selfLabels"] } } diff --git a/lexicons/app/bsky/actor/searchActors.json b/lexicons/app/bsky/actor/searchActors.json index 48fbacf4fcc..15ccb082238 100644 --- a/lexicons/app/bsky/actor/searchActors.json +++ b/lexicons/app/bsky/actor/searchActors.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Find actors (profiles) matching search criteria.", + "description": "Find actors (profiles) matching search criteria. Does not require auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/actor/searchActorsTypeahead.json b/lexicons/app/bsky/actor/searchActorsTypeahead.json index 495b7081c38..4e3cb1b4e88 100644 --- a/lexicons/app/bsky/actor/searchActorsTypeahead.json +++ b/lexicons/app/bsky/actor/searchActorsTypeahead.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Find actor suggestions for a prefix search term.", + "description": "Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. Does not require auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/embed/external.json b/lexicons/app/bsky/embed/external.json index 8946382835f..b9c8c1596d5 100644 --- a/lexicons/app/bsky/embed/external.json +++ b/lexicons/app/bsky/embed/external.json @@ -1,10 +1,10 @@ { "lexicon": 1, "id": "app.bsky.embed.external", - "description": "A representation of some externally linked content, embedded in another form of content.", "defs": { "main": { "type": "object", + "description": "A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post).", "required": ["external"], "properties": { "external": { diff --git a/lexicons/app/bsky/embed/images.json b/lexicons/app/bsky/embed/images.json index 5baa7ab3f74..307607bb7c2 100644 --- a/lexicons/app/bsky/embed/images.json +++ b/lexicons/app/bsky/embed/images.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "app.bsky.embed.images", - "description": "A set of images embedded in some other form of content.", + "description": "A set of images embedded in a Bluesky record (eg, a post).", "defs": { "main": { "type": "object", @@ -23,7 +23,10 @@ "accept": ["image/*"], "maxSize": 1000000 }, - "alt": { "type": "string" }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } }, @@ -51,9 +54,18 @@ "type": "object", "required": ["thumb", "fullsize", "alt"], "properties": { - "thumb": { "type": "string" }, - "fullsize": { "type": "string" }, - "alt": { "type": "string" }, + "thumb": { + "type": "string", + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." + }, + "fullsize": { + "type": "string", + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } } diff --git a/lexicons/app/bsky/embed/record.json b/lexicons/app/bsky/embed/record.json index 4b3d4f814a5..73b9488f1c5 100644 --- a/lexicons/app/bsky/embed/record.json +++ b/lexicons/app/bsky/embed/record.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "app.bsky.embed.record", - "description": "A representation of a record embedded in another form of content.", + "description": "A representation of a record embedded in a Bluesky record (eg, a post). For example, a quote-post, or sharing a feed generator record.", "defs": { "main": { "type": "object", @@ -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" ] } } @@ -36,7 +37,10 @@ "type": "ref", "ref": "app.bsky.actor.defs#profileViewBasic" }, - "value": { "type": "unknown" }, + "value": { + "type": "unknown", + "description": "The record data itself." + }, "labels": { "type": "array", "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } diff --git a/lexicons/app/bsky/embed/recordWithMedia.json b/lexicons/app/bsky/embed/recordWithMedia.json index 9bc5fe09048..46145464fe2 100644 --- a/lexicons/app/bsky/embed/recordWithMedia.json +++ b/lexicons/app/bsky/embed/recordWithMedia.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "app.bsky.embed.recordWithMedia", - "description": "A representation of a record embedded in another form of content, alongside other compatible embeds.", + "description": "A representation of a record embedded in a Bluesky record (eg, a post), alongside other compatible embeds. For example, a quote post and image, or a quote post and external URL card.", "defs": { "main": { "type": "object", diff --git a/lexicons/app/bsky/feed/defs.json b/lexicons/app/bsky/feed/defs.json index 15a7cb7a719..7c40ef3962f 100644 --- a/lexicons/app/bsky/feed/defs.json +++ b/lexicons/app/bsky/feed/defs.json @@ -36,6 +36,7 @@ }, "viewerState": { "type": "object", + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", "properties": { "repost": { "type": "string", "format": "at-uri" }, "like": { "type": "string", "format": "at-uri" }, @@ -136,6 +137,10 @@ }, "avatar": { "type": "string" }, "likeCount": { "type": "integer", "minimum": 0 }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, "viewer": { "type": "ref", "ref": "#generatorViewerState" }, "indexedAt": { "type": "string", "format": "datetime" } } diff --git a/lexicons/app/bsky/feed/describeFeedGenerator.json b/lexicons/app/bsky/feed/describeFeedGenerator.json index f95027183a1..0c7b8c8638e 100644 --- a/lexicons/app/bsky/feed/describeFeedGenerator.json +++ b/lexicons/app/bsky/feed/describeFeedGenerator.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get information about a feed generator, including policies and offered feed URIs.", + "description": "Get information about a feed generator, including policies and offered feed URIs. Does not require auth; implemented by Feed Generator services (not App View).", "output": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/feed/generator.json b/lexicons/app/bsky/feed/generator.json index 8c00884ad28..d0e361b72cb 100644 --- a/lexicons/app/bsky/feed/generator.json +++ b/lexicons/app/bsky/feed/generator.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A declaration of the existence of a feed generator.", + "description": "Record declaring of the existence of a feed generator, and containing metadata about it. The record can exist in any repository.", "key": "any", "record": { "type": "object", @@ -32,6 +32,7 @@ }, "labels": { "type": "union", + "description": "Self-label values", "refs": ["com.atproto.label.defs#selfLabels"] }, "createdAt": { "type": "string", "format": "datetime" } diff --git a/lexicons/app/bsky/feed/getActorFeeds.json b/lexicons/app/bsky/feed/getActorFeeds.json index a0620477bc3..9a7dae0ad5d 100644 --- a/lexicons/app/bsky/feed/getActorFeeds.json +++ b/lexicons/app/bsky/feed/getActorFeeds.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of feeds created by the actor.", + "description": "Get a list of feeds (feed generator records) created by the actor (in the actor's repo).", "parameters": { "type": "params", "required": ["actor"], diff --git a/lexicons/app/bsky/feed/getActorLikes.json b/lexicons/app/bsky/feed/getActorLikes.json index b3baa58a457..22f8ed984ac 100644 --- a/lexicons/app/bsky/feed/getActorLikes.json +++ b/lexicons/app/bsky/feed/getActorLikes.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of posts liked by an actor.", + "description": "Get a list of posts liked by an actor. Does not require auth.", "parameters": { "type": "params", "required": ["actor"], diff --git a/lexicons/app/bsky/feed/getAuthorFeed.json b/lexicons/app/bsky/feed/getAuthorFeed.json index 1939fa9a49d..90e4d1a7708 100644 --- a/lexicons/app/bsky/feed/getAuthorFeed.json +++ b/lexicons/app/bsky/feed/getAuthorFeed.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a view of an actor's feed.", + "description": "Get a view of an actor's 'author feed' (post and reposts by the author). Does not require auth.", "parameters": { "type": "params", "required": ["actor"], @@ -19,6 +19,7 @@ "cursor": { "type": "string" }, "filter": { "type": "string", + "description": "Combinations of post/repost types to include in response.", "knownValues": [ "posts_with_replies", "posts_no_replies", diff --git a/lexicons/app/bsky/feed/getFeed.json b/lexicons/app/bsky/feed/getFeed.json index 84407bde155..ada3098b56f 100644 --- a/lexicons/app/bsky/feed/getFeed.json +++ b/lexicons/app/bsky/feed/getFeed.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a hydrated feed from an actor's selected feed generator.", + "description": "Get a hydrated feed from an actor's selected feed generator. Implemented by App View.", "parameters": { "type": "params", "required": ["feed"], diff --git a/lexicons/app/bsky/feed/getFeedGenerator.json b/lexicons/app/bsky/feed/getFeedGenerator.json index 8b3d4d0551a..190eca85b26 100644 --- a/lexicons/app/bsky/feed/getFeedGenerator.json +++ b/lexicons/app/bsky/feed/getFeedGenerator.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get information about a feed generator.", + "description": "Get information about a feed generator. Implemented by AppView.", "parameters": { "type": "params", "required": ["feed"], "properties": { - "feed": { "type": "string", "format": "at-uri" } + "feed": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the feed generator record." + } } }, "output": { @@ -22,8 +26,14 @@ "type": "ref", "ref": "app.bsky.feed.defs#generatorView" }, - "isOnline": { "type": "boolean" }, - "isValid": { "type": "boolean" } + "isOnline": { + "type": "boolean", + "description": "Indicates whether the feed generator service has been online recently, or else seems to be inactive." + }, + "isValid": { + "type": "boolean", + "description": "Indicates whether the feed generator service is compatible with the record declaration." + } } } } diff --git a/lexicons/app/bsky/feed/getFeedSkeleton.json b/lexicons/app/bsky/feed/getFeedSkeleton.json index 03f3ba04c0f..2bcaa13d4f0 100644 --- a/lexicons/app/bsky/feed/getFeedSkeleton.json +++ b/lexicons/app/bsky/feed/getFeedSkeleton.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get a skeleton of a feed provided by a feed generator.", + "description": "Get a skeleton of a feed provided by a feed generator. Auth is optional, depending on provider requirements, and provides the DID of the requester. Implemented by Feed Generator Service.", "parameters": { "type": "params", "required": ["feed"], "properties": { - "feed": { "type": "string", "format": "at-uri" }, + "feed": { + "type": "string", + "format": "at-uri", + "description": "Reference to feed generator record describing the specific feed being requested." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/feed/getLikes.json b/lexicons/app/bsky/feed/getLikes.json index ffcbc01ac53..d2c5b1a77df 100644 --- a/lexicons/app/bsky/feed/getLikes.json +++ b/lexicons/app/bsky/feed/getLikes.json @@ -4,13 +4,21 @@ "defs": { "main": { "type": "query", - "description": "Get the list of likes.", + "description": "Get like records which reference a subject (by AT-URI and CID).", "parameters": { "type": "params", "required": ["uri"], "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the subject (eg, a post record)." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID of the subject record (aka, specific version of record), to filter likes." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/feed/getListFeed.json b/lexicons/app/bsky/feed/getListFeed.json index 4c5358fcfd7..9dd9fdc70f3 100644 --- a/lexicons/app/bsky/feed/getListFeed.json +++ b/lexicons/app/bsky/feed/getListFeed.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get a view of a recent posts from actors in a list.", + "description": "Get a feed of recent posts from a list (posts and reposts from any actors on the list). Does not require auth.", "parameters": { "type": "params", "required": ["list"], "properties": { - "list": { "type": "string", "format": "at-uri" }, + "list": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) to the list record." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/feed/getPostThread.json b/lexicons/app/bsky/feed/getPostThread.json index b983617041f..89e99d9c6d7 100644 --- a/lexicons/app/bsky/feed/getPostThread.json +++ b/lexicons/app/bsky/feed/getPostThread.json @@ -4,20 +4,26 @@ "defs": { "main": { "type": "query", - "description": "Get posts in a thread.", + "description": "Get posts in a thread. Does not require auth, but additional metadata and filtering will be applied for authed requests.", "parameters": { "type": "params", "required": ["uri"], "properties": { - "uri": { "type": "string", "format": "at-uri" }, + "uri": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) to post record." + }, "depth": { "type": "integer", + "description": "How many levels of reply depth should be included in response.", "default": 6, "minimum": 0, "maximum": 1000 }, "parentHeight": { "type": "integer", + "description": "How many levels of parent (and grandparent, etc) post to include.", "default": 80, "minimum": 0, "maximum": 1000 diff --git a/lexicons/app/bsky/feed/getPosts.json b/lexicons/app/bsky/feed/getPosts.json index c985a5cf033..e555ee16326 100644 --- a/lexicons/app/bsky/feed/getPosts.json +++ b/lexicons/app/bsky/feed/getPosts.json @@ -4,13 +4,14 @@ "defs": { "main": { "type": "query", - "description": "Get a view of an actor's feed.", + "description": "Gets post views for a specified list of posts (by AT-URI). This is sometimes referred to as 'hydrating' a 'feed skeleton'.", "parameters": { "type": "params", "required": ["uris"], "properties": { "uris": { "type": "array", + "description": "List of post AT-URIs to return hydrated views for.", "items": { "type": "string", "format": "at-uri" }, "maxLength": 25 } diff --git a/lexicons/app/bsky/feed/getRepostedBy.json b/lexicons/app/bsky/feed/getRepostedBy.json index 99abc6d5cde..db39534658b 100644 --- a/lexicons/app/bsky/feed/getRepostedBy.json +++ b/lexicons/app/bsky/feed/getRepostedBy.json @@ -4,13 +4,21 @@ "defs": { "main": { "type": "query", - "description": "Get a list of reposts.", + "description": "Get a list of reposts for a given post.", "parameters": { "type": "params", "required": ["uri"], "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, + "uri": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) of post record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "If supplied, filters to reposts of specific version (by CID) of the post record." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/feed/getSuggestedFeeds.json b/lexicons/app/bsky/feed/getSuggestedFeeds.json index de7c4fef753..e643d3391e5 100644 --- a/lexicons/app/bsky/feed/getSuggestedFeeds.json +++ b/lexicons/app/bsky/feed/getSuggestedFeeds.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of suggested feeds for the viewer.", + "description": "Get a list of suggested feeds (feed generators) for the requesting account.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/feed/getTimeline.json b/lexicons/app/bsky/feed/getTimeline.json index c3b116381c6..816380fe680 100644 --- a/lexicons/app/bsky/feed/getTimeline.json +++ b/lexicons/app/bsky/feed/getTimeline.json @@ -4,11 +4,14 @@ "defs": { "main": { "type": "query", - "description": "Get a view of the actor's home timeline.", + "description": "Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed.", "parameters": { "type": "params", "properties": { - "algorithm": { "type": "string" }, + "algorithm": { + "type": "string", + "description": "Variant 'algorithm' for timeline. Implementation-specific. NOTE: most feed flexibility has been moved to feed generator mechanism." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/feed/like.json b/lexicons/app/bsky/feed/like.json index d82f93bbb1b..c0de3b71e92 100644 --- a/lexicons/app/bsky/feed/like.json +++ b/lexicons/app/bsky/feed/like.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a like.", + "description": "Record declaring a 'like' of a piece of subject content.", "key": "tid", "record": { "type": "object", diff --git a/lexicons/app/bsky/feed/post.json b/lexicons/app/bsky/feed/post.json index d5f92969253..b9b236b4f81 100644 --- a/lexicons/app/bsky/feed/post.json +++ b/lexicons/app/bsky/feed/post.json @@ -4,20 +4,26 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a post.", + "description": "Record containing a Bluesky post.", "key": "tid", "record": { "type": "object", "required": ["text", "createdAt"], "properties": { - "text": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 }, + "text": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300, + "description": "The primary post content. May be an empty string, if there are embeds." + }, "entities": { "type": "array", - "description": "Deprecated: replaced by app.bsky.richtext.facet.", + "description": "DEPRECATED: replaced by app.bsky.richtext.facet.", "items": { "type": "ref", "ref": "#entity" } }, "facets": { "type": "array", + "description": "Annotations of text (mentions, URLs, hashtags, etc)", "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } }, "reply": { "type": "ref", "ref": "#replyRef" }, @@ -32,20 +38,26 @@ }, "langs": { "type": "array", + "description": "Indicates human language of post primary text content.", "maxLength": 3, "items": { "type": "string", "format": "language" } }, "labels": { "type": "union", + "description": "Self-label values for this post. Effectively content warnings.", "refs": ["com.atproto.label.defs#selfLabels"] }, "tags": { "type": "array", + "description": "Additional hashtags, in addition to any included in post text and facets.", "maxLength": 8, - "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }, - "description": "Additional non-inline tags describing this post." + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } }, - "createdAt": { "type": "string", "format": "datetime" } + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } } } }, diff --git a/lexicons/app/bsky/feed/repost.json b/lexicons/app/bsky/feed/repost.json index 4dbef10b319..028fd627152 100644 --- a/lexicons/app/bsky/feed/repost.json +++ b/lexicons/app/bsky/feed/repost.json @@ -3,7 +3,7 @@ "id": "app.bsky.feed.repost", "defs": { "main": { - "description": "A declaration of a repost.", + "description": "Record representing a 'repost' of an existing Bluesky post.", "type": "record", "key": "tid", "record": { diff --git a/lexicons/app/bsky/feed/searchPosts.json b/lexicons/app/bsky/feed/searchPosts.json index a3e0bc47f03..c89655dd9db 100644 --- a/lexicons/app/bsky/feed/searchPosts.json +++ b/lexicons/app/bsky/feed/searchPosts.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Find posts matching search criteria.", + "description": "Find posts matching search criteria, returning views of those posts.", "parameters": { "type": "params", "required": ["q"], diff --git a/lexicons/app/bsky/feed/threadgate.json b/lexicons/app/bsky/feed/threadgate.json index 7969b6360a6..ff258da4d30 100644 --- a/lexicons/app/bsky/feed/threadgate.json +++ b/lexicons/app/bsky/feed/threadgate.json @@ -5,12 +5,16 @@ "main": { "type": "record", "key": "tid", - "description": "Defines interaction gating rules for a thread. The rkey of the threadgate record should match the rkey of the thread's root post.", + "description": "Record defining interaction gating rules for a thread (aka, reply controls). The record key (rkey) of the threadgate record must match the record key of the thread's root post, and that record must be in the same repository..", "record": { "type": "object", "required": ["post", "createdAt"], "properties": { - "post": { "type": "string", "format": "at-uri" }, + "post": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) to the post record." + }, "allow": { "type": "array", "maxLength": 5, diff --git a/lexicons/app/bsky/graph/block.json b/lexicons/app/bsky/graph/block.json index 6231eb04e10..b64b11a956d 100644 --- a/lexicons/app/bsky/graph/block.json +++ b/lexicons/app/bsky/graph/block.json @@ -4,13 +4,17 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a block.", + "description": "Record declaring a 'block' relationship against another account. NOTE: blocks are public in Bluesky; see blog posts for details.", "key": "tid", "record": { "type": "object", "required": ["subject", "createdAt"], "properties": { - "subject": { "type": "string", "format": "did" }, + "subject": { + "type": "string", + "format": "did", + "description": "DID of the account to be blocked." + }, "createdAt": { "type": "string", "format": "datetime" } } } diff --git a/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index 737d984d08b..be718f20a16 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -11,6 +11,10 @@ "name": { "type": "string", "maxLength": 64, "minLength": 1 }, "purpose": { "type": "ref", "ref": "#listPurpose" }, "avatar": { "type": "string" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, "viewer": { "type": "ref", "ref": "#listViewerState" }, "indexedAt": { "type": "string", "format": "datetime" } } @@ -34,6 +38,10 @@ "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } }, "avatar": { "type": "string" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, "viewer": { "type": "ref", "ref": "#listViewerState" }, "indexedAt": { "type": "string", "format": "datetime" } } diff --git a/lexicons/app/bsky/graph/follow.json b/lexicons/app/bsky/graph/follow.json index df4f4319d92..dd6347ac76d 100644 --- a/lexicons/app/bsky/graph/follow.json +++ b/lexicons/app/bsky/graph/follow.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a social follow.", + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", "key": "tid", "record": { "type": "object", diff --git a/lexicons/app/bsky/graph/getBlocks.json b/lexicons/app/bsky/graph/getBlocks.json index bbfe956fbe0..79a28f66a52 100644 --- a/lexicons/app/bsky/graph/getBlocks.json +++ b/lexicons/app/bsky/graph/getBlocks.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of who the actor is blocking.", + "description": "Enumerates which accounts the requesting account is currently blocking. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/graph/getFollowers.json b/lexicons/app/bsky/graph/getFollowers.json index 378c7a7a339..a6c4facd653 100644 --- a/lexicons/app/bsky/graph/getFollowers.json +++ b/lexicons/app/bsky/graph/getFollowers.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of an actor's followers.", + "description": "Enumerates accounts which follow a specified account (actor).", "parameters": { "type": "params", "required": ["actor"], diff --git a/lexicons/app/bsky/graph/getFollows.json b/lexicons/app/bsky/graph/getFollows.json index b90f7613889..81f1b6abe49 100644 --- a/lexicons/app/bsky/graph/getFollows.json +++ b/lexicons/app/bsky/graph/getFollows.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of who the actor follows.", + "description": "Enumerates accounts which a specified account (actor) follows.", "parameters": { "type": "params", "required": ["actor"], diff --git a/lexicons/app/bsky/graph/getList.json b/lexicons/app/bsky/graph/getList.json index fd24668e5bd..cb95a003a56 100644 --- a/lexicons/app/bsky/graph/getList.json +++ b/lexicons/app/bsky/graph/getList.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get a list of actors.", + "description": "Gets a 'view' (with additional context) of a specified list.", "parameters": { "type": "params", "required": ["list"], "properties": { - "list": { "type": "string", "format": "at-uri" }, + "list": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) of the list record to hydrate." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/graph/getListBlocks.json b/lexicons/app/bsky/graph/getListBlocks.json index 9f9f59821f2..1bc976617ef 100644 --- a/lexicons/app/bsky/graph/getListBlocks.json +++ b/lexicons/app/bsky/graph/getListBlocks.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get lists that the actor is blocking.", + "description": "Get mod lists that the requesting account (actor) is blocking. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/graph/getListMutes.json b/lexicons/app/bsky/graph/getListMutes.json index 8d42ac40f9c..a56a8257643 100644 --- a/lexicons/app/bsky/graph/getListMutes.json +++ b/lexicons/app/bsky/graph/getListMutes.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get lists that the actor is muting.", + "description": "Enumerates mod lists that the requesting account (actor) currently has muted. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/graph/getLists.json b/lexicons/app/bsky/graph/getLists.json index 602dd15307d..127b13e5558 100644 --- a/lexicons/app/bsky/graph/getLists.json +++ b/lexicons/app/bsky/graph/getLists.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get a list of lists that belong to an actor.", + "description": "Enumerates the lists created by a specified account (actor).", "parameters": { "type": "params", "required": ["actor"], "properties": { - "actor": { "type": "string", "format": "at-identifier" }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "The account (actor) to enumerate lists from." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/graph/getMutes.json b/lexicons/app/bsky/graph/getMutes.json index 8ceae00f607..22eaf0d384c 100644 --- a/lexicons/app/bsky/graph/getMutes.json +++ b/lexicons/app/bsky/graph/getMutes.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of who the actor mutes.", + "description": "Enumerates accounts that the requesting account (actor) currently has muted. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/graph/getRelationships.json b/lexicons/app/bsky/graph/getRelationships.json index ccd495dea7d..03490a25ec2 100644 --- a/lexicons/app/bsky/graph/getRelationships.json +++ b/lexicons/app/bsky/graph/getRelationships.json @@ -4,14 +4,19 @@ "defs": { "main": { "type": "query", - "description": "Enumerates public relationships between one account, and a list of other accounts", + "description": "Enumerates public relationships between one account, and a list of other accounts. Does not require auth.", "parameters": { "type": "params", "required": ["actor"], "properties": { - "actor": { "type": "string", "format": "at-identifier" }, + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Primary account requesting relationships for." + }, "others": { "type": "array", + "description": "List of 'other' accounts to be related back to the primary.", "maxLength": 30, "items": { "type": "string", diff --git a/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json index 32873a537c9..5b0cfdebb70 100644 --- a/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json +++ b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get suggested follows related to a given actor.", + "description": "Enumerates follows similar to a given account (actor). Expected use is to recommend additional accounts immediately after following one account.", "parameters": { "type": "params", "required": ["actor"], diff --git a/lexicons/app/bsky/graph/list.json b/lexicons/app/bsky/graph/list.json index ccc845a6926..131114126d3 100644 --- a/lexicons/app/bsky/graph/list.json +++ b/lexicons/app/bsky/graph/list.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A declaration of a list of actors.", + "description": "Record representing a list of accounts (actors). Scope includes both moderation-oriented lists and curration-oriented lists.", "key": "tid", "record": { "type": "object", @@ -12,9 +12,15 @@ "properties": { "purpose": { "type": "ref", + "description": "Defines the purpose of the list (aka, moderation-oriented or curration-oriented)", "ref": "app.bsky.graph.defs#listPurpose" }, - "name": { "type": "string", "maxLength": 64, "minLength": 1 }, + "name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "description": "Display name for list; can not be empty." + }, "description": { "type": "string", "maxGraphemes": 300, diff --git a/lexicons/app/bsky/graph/listblock.json b/lexicons/app/bsky/graph/listblock.json index b3a839c5316..df2e17f3c30 100644 --- a/lexicons/app/bsky/graph/listblock.json +++ b/lexicons/app/bsky/graph/listblock.json @@ -4,13 +4,17 @@ "defs": { "main": { "type": "record", - "description": "A block of an entire list of actors.", + "description": "Record representing a block relationship against an entire an entire list of accounts (actors).", "key": "tid", "record": { "type": "object", "required": ["subject", "createdAt"], "properties": { - "subject": { "type": "string", "format": "at-uri" }, + "subject": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) to the mod list record." + }, "createdAt": { "type": "string", "format": "datetime" } } } diff --git a/lexicons/app/bsky/graph/listitem.json b/lexicons/app/bsky/graph/listitem.json index 2eafb1340be..adbd96e77da 100644 --- a/lexicons/app/bsky/graph/listitem.json +++ b/lexicons/app/bsky/graph/listitem.json @@ -4,14 +4,22 @@ "defs": { "main": { "type": "record", - "description": "An item under a declared list of actors.", + "description": "Record representing an account's inclusion on a specific list. The AppView will ignore duplicate listitem records.", "key": "tid", "record": { "type": "object", "required": ["subject", "list", "createdAt"], "properties": { - "subject": { "type": "string", "format": "did" }, - "list": { "type": "string", "format": "at-uri" }, + "subject": { + "type": "string", + "format": "did", + "description": "The account which is included on the list." + }, + "list": { + "type": "string", + "format": "at-uri", + "description": "Reference (AT-URI) to the list record (app.bsky.graph.list)." + }, "createdAt": { "type": "string", "format": "datetime" } } } diff --git a/lexicons/app/bsky/graph/muteActor.json b/lexicons/app/bsky/graph/muteActor.json index f1c3dd18f64..c2bf09a3b37 100644 --- a/lexicons/app/bsky/graph/muteActor.json +++ b/lexicons/app/bsky/graph/muteActor.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Mute an actor by DID or handle.", + "description": "Creates a mute relationship for the specified account. Mutes are private in Bluesky. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/graph/muteActorList.json b/lexicons/app/bsky/graph/muteActorList.json index c75cc783c14..ad05e6349d7 100644 --- a/lexicons/app/bsky/graph/muteActorList.json +++ b/lexicons/app/bsky/graph/muteActorList.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Mute a list of actors.", + "description": "Creates a mute relationship for the specified list of accounts. Mutes are private in Bluesky. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/graph/unmuteActor.json b/lexicons/app/bsky/graph/unmuteActor.json index 114af204890..bcea72db59d 100644 --- a/lexicons/app/bsky/graph/unmuteActor.json +++ b/lexicons/app/bsky/graph/unmuteActor.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Unmute an actor by DID or handle.", + "description": "Unmutes the specified account. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/graph/unmuteActorList.json b/lexicons/app/bsky/graph/unmuteActorList.json index d9644cddc8e..a597838e112 100644 --- a/lexicons/app/bsky/graph/unmuteActorList.json +++ b/lexicons/app/bsky/graph/unmuteActorList.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Unmute a list of actors.", + "description": "Unmutes the specified list of accounts. Requires auth.", "input": { "encoding": "application/json", "schema": { 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/app/bsky/notification/getUnreadCount.json b/lexicons/app/bsky/notification/getUnreadCount.json index ab716b2a436..5eebbbf4eb5 100644 --- a/lexicons/app/bsky/notification/getUnreadCount.json +++ b/lexicons/app/bsky/notification/getUnreadCount.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get the count of unread notifications.", + "description": "Count the number of unread notifications for the requesting account. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/notification/listNotifications.json b/lexicons/app/bsky/notification/listNotifications.json index ea74c5fba53..6c5095e1eb2 100644 --- a/lexicons/app/bsky/notification/listNotifications.json +++ b/lexicons/app/bsky/notification/listNotifications.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a list of notifications.", + "description": "Enumerate notifications for the requesting account. Requires auth.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/app/bsky/notification/registerPush.json b/lexicons/app/bsky/notification/registerPush.json index 80819ece46f..c4e50d1108c 100644 --- a/lexicons/app/bsky/notification/registerPush.json +++ b/lexicons/app/bsky/notification/registerPush.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Register for push notifications with a service.", + "description": "Register to receive push notifications, via a specified service, for the requesting account. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/notification/updateSeen.json b/lexicons/app/bsky/notification/updateSeen.json index 33626343e51..84bb0e7d52f 100644 --- a/lexicons/app/bsky/notification/updateSeen.json +++ b/lexicons/app/bsky/notification/updateSeen.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Notify server that the user has seen notifications.", + "description": "Notify server that the requesting account has seen notifications. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/app/bsky/richtext/facet.json b/lexicons/app/bsky/richtext/facet.json index ea8f2cba288..388a3a5e0ef 100644 --- a/lexicons/app/bsky/richtext/facet.json +++ b/lexicons/app/bsky/richtext/facet.json @@ -4,6 +4,7 @@ "defs": { "main": { "type": "object", + "description": "Annotation of a sub-string within rich text.", "required": ["index", "features"], "properties": { "index": { "type": "ref", "ref": "#byteSlice" }, @@ -15,7 +16,7 @@ }, "mention": { "type": "object", - "description": "A facet feature for actor mentions.", + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", "required": ["did"], "properties": { "did": { "type": "string", "format": "did" } @@ -23,7 +24,7 @@ }, "link": { "type": "object", - "description": "A facet feature for links.", + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", "required": ["uri"], "properties": { "uri": { "type": "string", "format": "uri" } @@ -31,7 +32,7 @@ }, "tag": { "type": "object", - "description": "A hashtag.", + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", "required": ["tag"], "properties": { "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } @@ -39,7 +40,7 @@ }, "byteSlice": { "type": "object", - "description": "A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings.", + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", "required": ["byteStart", "byteEnd"], "properties": { "byteStart": { "type": "integer", "minimum": 0 }, diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 5a65ae31562..a2fcd91d5b9 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -10,283 +10,6 @@ "ref": { "type": "string" } } }, - "modEventView": { - "type": "object", - "required": [ - "id", - "event", - "subject", - "subjectBlobCids", - "createdBy", - "createdAt" - ], - "properties": { - "id": { "type": "integer" }, - "event": { - "type": "union", - "refs": [ - "#modEventTakedown", - "#modEventReverseTakedown", - "#modEventComment", - "#modEventReport", - "#modEventLabel", - "#modEventAcknowledge", - "#modEventEscalate", - "#modEventMute", - "#modEventEmail" - ] - }, - "subject": { - "type": "union", - "refs": ["#repoRef", "com.atproto.repo.strongRef"] - }, - "subjectBlobCids": { "type": "array", "items": { "type": "string" } }, - "createdBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "string", "format": "datetime" }, - "creatorHandle": { "type": "string" }, - "subjectHandle": { "type": "string" } - } - }, - "modEventViewDetail": { - "type": "object", - "required": [ - "id", - "event", - "subject", - "subjectBlobs", - "createdBy", - "createdAt" - ], - "properties": { - "id": { "type": "integer" }, - "event": { - "type": "union", - "refs": [ - "#modEventTakedown", - "#modEventReverseTakedown", - "#modEventComment", - "#modEventReport", - "#modEventLabel", - "#modEventAcknowledge", - "#modEventEscalate", - "#modEventMute", - "#modEventResolveAppeal" - ] - }, - "subject": { - "type": "union", - "refs": [ - "#repoView", - "#repoViewNotFound", - "#recordView", - "#recordViewNotFound" - ] - }, - "subjectBlobs": { - "type": "array", - "items": { "type": "ref", "ref": "#blobView" } - }, - "createdBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "string", "format": "datetime" } - } - }, - "reportView": { - "type": "object", - "required": [ - "id", - "reasonType", - "subject", - "reportedBy", - "createdAt", - "resolvedByActionIds" - ], - "properties": { - "id": { "type": "integer" }, - "reasonType": { - "type": "ref", - "ref": "com.atproto.moderation.defs#reasonType" - }, - "comment": { "type": "string" }, - "subjectRepoHandle": { "type": "string" }, - "subject": { - "type": "union", - "refs": ["#repoRef", "com.atproto.repo.strongRef"] - }, - "reportedBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "string", "format": "datetime" }, - "resolvedByActionIds": { - "type": "array", - "items": { "type": "integer" } - } - } - }, - "subjectStatusView": { - "type": "object", - "required": ["id", "subject", "createdAt", "updatedAt", "reviewState"], - "properties": { - "id": { "type": "integer" }, - "subject": { - "type": "union", - "refs": ["#repoRef", "com.atproto.repo.strongRef"] - }, - "subjectBlobCids": { - "type": "array", - "items": { "type": "string", "format": "cid" } - }, - "subjectRepoHandle": { "type": "string" }, - "updatedAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp referencing when the last update was made to the moderation status of the subject" - }, - "createdAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp referencing the first moderation status impacting event was emitted on the subject" - }, - "reviewState": { - "type": "ref", - "ref": "#subjectReviewState" - }, - "comment": { - "type": "string", - "description": "Sticky comment on the subject." - }, - "muteUntil": { - "type": "string", - "format": "datetime" - }, - "lastReviewedBy": { - "type": "string", - "format": "did" - }, - "lastReviewedAt": { - "type": "string", - "format": "datetime" - }, - "lastReportedAt": { - "type": "string", - "format": "datetime" - }, - "lastAppealedAt": { - "type": "string", - "format": "datetime", - "description": "Timestamp referencing when the author of the subject appealed a moderation action" - }, - "takendown": { - "type": "boolean" - }, - "appealed": { - "type": "boolean", - "description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." - }, - "suspendUntil": { - "type": "string", - "format": "datetime" - } - } - }, - "reportViewDetail": { - "type": "object", - "required": [ - "id", - "reasonType", - "subject", - "reportedBy", - "createdAt", - "resolvedByActions" - ], - "properties": { - "id": { "type": "integer" }, - "reasonType": { - "type": "ref", - "ref": "com.atproto.moderation.defs#reasonType" - }, - "comment": { "type": "string" }, - "subject": { - "type": "union", - "refs": [ - "#repoView", - "#repoViewNotFound", - "#recordView", - "#recordViewNotFound" - ] - }, - "subjectStatus": { - "type": "ref", - "ref": "com.atproto.admin.defs#subjectStatusView" - }, - "reportedBy": { "type": "string", "format": "did" }, - "createdAt": { "type": "string", "format": "datetime" }, - "resolvedByActions": { - "type": "array", - "items": { - "type": "ref", - "ref": "com.atproto.admin.defs#modEventView" - } - } - } - }, - "repoView": { - "type": "object", - "required": [ - "did", - "handle", - "relatedRecords", - "indexedAt", - "moderation" - ], - "properties": { - "did": { "type": "string", "format": "did" }, - "handle": { "type": "string", "format": "handle" }, - "email": { "type": "string" }, - "relatedRecords": { "type": "array", "items": { "type": "unknown" } }, - "indexedAt": { "type": "string", "format": "datetime" }, - "moderation": { "type": "ref", "ref": "#moderation" }, - "invitedBy": { - "type": "ref", - "ref": "com.atproto.server.defs#inviteCode" - }, - "invitesDisabled": { "type": "boolean" }, - "inviteNote": { "type": "string" } - } - }, - "repoViewDetail": { - "type": "object", - "required": [ - "did", - "handle", - "relatedRecords", - "indexedAt", - "moderation" - ], - "properties": { - "did": { "type": "string", "format": "did" }, - "handle": { "type": "string", "format": "handle" }, - "email": { "type": "string" }, - "relatedRecords": { "type": "array", "items": { "type": "unknown" } }, - "indexedAt": { "type": "string", "format": "datetime" }, - "moderation": { "type": "ref", "ref": "#moderationDetail" }, - "labels": { - "type": "array", - "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } - }, - "invitedBy": { - "type": "ref", - "ref": "com.atproto.server.defs#inviteCode" - }, - "invites": { - "type": "array", - "items": { - "type": "ref", - "ref": "com.atproto.server.defs#inviteCode" - } - }, - "invitesDisabled": { "type": "boolean" }, - "inviteNote": { "type": "string" }, - "emailConfirmedAt": { "type": "string", "format": "datetime" } - } - }, "accountView": { "type": "object", "required": ["did", "handle", "indexedAt"], @@ -312,13 +35,6 @@ "inviteNote": { "type": "string" } } }, - "repoViewNotFound": { - "type": "object", - "required": ["did"], - "properties": { - "did": { "type": "string", "format": "did" } - } - }, "repoRef": { "type": "object", "required": ["did"], @@ -334,288 +50,6 @@ "cid": { "type": "string", "format": "cid" }, "recordUri": { "type": "string", "format": "at-uri" } } - }, - "recordView": { - "type": "object", - "required": [ - "uri", - "cid", - "value", - "blobCids", - "indexedAt", - "moderation", - "repo" - ], - "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, - "value": { "type": "unknown" }, - "blobCids": { - "type": "array", - "items": { "type": "string", "format": "cid" } - }, - "indexedAt": { "type": "string", "format": "datetime" }, - "moderation": { "type": "ref", "ref": "#moderation" }, - "repo": { "type": "ref", "ref": "#repoView" } - } - }, - "recordViewDetail": { - "type": "object", - "required": [ - "uri", - "cid", - "value", - "blobs", - "indexedAt", - "moderation", - "repo" - ], - "properties": { - "uri": { "type": "string", "format": "at-uri" }, - "cid": { "type": "string", "format": "cid" }, - "value": { "type": "unknown" }, - "blobs": { - "type": "array", - "items": { "type": "ref", "ref": "#blobView" } - }, - "labels": { - "type": "array", - "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } - }, - "indexedAt": { "type": "string", "format": "datetime" }, - "moderation": { "type": "ref", "ref": "#moderationDetail" }, - "repo": { "type": "ref", "ref": "#repoView" } - } - }, - "recordViewNotFound": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { "type": "string", "format": "at-uri" } - } - }, - "moderation": { - "type": "object", - "properties": { - "subjectStatus": { "type": "ref", "ref": "#subjectStatusView" } - } - }, - "moderationDetail": { - "type": "object", - "properties": { - "subjectStatus": { - "type": "ref", - "ref": "#subjectStatusView" - } - } - }, - "blobView": { - "type": "object", - "required": ["cid", "mimeType", "size", "createdAt"], - "properties": { - "cid": { "type": "string", "format": "cid" }, - "mimeType": { "type": "string" }, - "size": { "type": "integer" }, - "createdAt": { "type": "string", "format": "datetime" }, - "details": { - "type": "union", - "refs": ["#imageDetails", "#videoDetails"] - }, - "moderation": { "type": "ref", "ref": "#moderation" } - } - }, - "imageDetails": { - "type": "object", - "required": ["width", "height"], - "properties": { - "width": { "type": "integer" }, - "height": { "type": "integer" } - } - }, - "videoDetails": { - "type": "object", - "required": ["width", "height", "length"], - "properties": { - "width": { "type": "integer" }, - "height": { "type": "integer" }, - "length": { "type": "integer" } - } - }, - "subjectReviewState": { - "type": "string", - "knownValues": ["#reviewOpen", "#reviewEscalated", "#reviewClosed"] - }, - "reviewOpen": { - "type": "token", - "description": "Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator" - }, - "reviewEscalated": { - "type": "token", - "description": "Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator" - }, - "reviewClosed": { - "type": "token", - "description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator" - }, - "modEventTakedown": { - "type": "object", - "description": "Take down a subject permanently or temporarily", - "properties": { - "comment": { - "type": "string" - }, - "durationInHours": { - "type": "integer", - "description": "Indicates how long the takedown should be in effect before automatically expiring." - } - } - }, - "modEventReverseTakedown": { - "type": "object", - "description": "Revert take down action on a subject", - "properties": { - "comment": { - "type": "string", - "description": "Describe reasoning behind the reversal." - } - } - }, - "modEventResolveAppeal": { - "type": "object", - "description": "Resolve appeal on a subject", - "properties": { - "comment": { - "type": "string", - "description": "Describe resolution." - } - } - }, - "modEventComment": { - "type": "object", - "description": "Add a comment to a subject", - "required": ["comment"], - "properties": { - "comment": { - "type": "string" - }, - "sticky": { - "type": "boolean", - "description": "Make the comment persistent on the subject" - } - } - }, - "modEventReport": { - "type": "object", - "description": "Report a subject", - "required": ["reportType"], - "properties": { - "comment": { - "type": "string" - }, - "reportType": { - "type": "ref", - "ref": "com.atproto.moderation.defs#reasonType" - } - } - }, - "modEventLabel": { - "type": "object", - "description": "Apply/Negate labels on a subject", - "required": ["createLabelVals", "negateLabelVals"], - "properties": { - "comment": { - "type": "string" - }, - "createLabelVals": { - "type": "array", - "items": { "type": "string" } - }, - "negateLabelVals": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "modEventAcknowledge": { - "type": "object", - "properties": { - "comment": { "type": "string" } - } - }, - "modEventEscalate": { - "type": "object", - "properties": { - "comment": { "type": "string" } - } - }, - "modEventMute": { - "type": "object", - "description": "Mute incoming reports on a subject", - "required": ["durationInHours"], - "properties": { - "comment": { "type": "string" }, - "durationInHours": { - "type": "integer", - "description": "Indicates how long the subject should remain muted." - } - } - }, - "modEventUnmute": { - "type": "object", - "description": "Unmute action on a subject", - "properties": { - "comment": { - "type": "string", - "description": "Describe reasoning behind the reversal." - } - } - }, - "modEventEmail": { - "type": "object", - "description": "Keep a log of outgoing email to a user", - "required": ["subjectLine"], - "properties": { - "subjectLine": { - "type": "string", - "description": "The subject line of the email sent to the user." - }, - "comment": { - "type": "string", - "description": "Additional comment about the outgoing comm." - } - } - }, - "communicationTemplateView": { - "type": "object", - "required": [ - "id", - "name", - "contentMarkdown", - "disabled", - "lastUpdatedBy", - "createdAt", - "updatedAt" - ], - "properties": { - "id": { "type": "string" }, - "name": { "type": "string", "description": "Name of the template." }, - "subject": { - "type": "string", - "description": "Content of the template, can contain markdown and variable placeholders." - }, - "contentMarkdown": { - "type": "string", - "description": "Subject of the message, used in emails." - }, - "disabled": { "type": "boolean" }, - "lastUpdatedBy": { - "type": "string", - "format": "did", - "description": "DID of the user who last updated the template." - }, - "createdAt": { "type": "string", "format": "datetime" }, - "updatedAt": { "type": "string", "format": "datetime" } - } } } } diff --git a/lexicons/com/atproto/admin/queryModerationEvents.json b/lexicons/com/atproto/admin/queryModerationEvents.json deleted file mode 100644 index 70af1bf8ae5..00000000000 --- a/lexicons/com/atproto/admin/queryModerationEvents.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.admin.queryModerationEvents", - "defs": { - "main": { - "type": "query", - "description": "List moderation events related to a subject.", - "parameters": { - "type": "params", - "properties": { - "types": { - "type": "array", - "items": { "type": "string" }, - "description": "The types of events (fully qualified string in the format of com.atproto.admin#modEvent) to filter by. If not specified, all events are returned." - }, - "createdBy": { - "type": "string", - "format": "did" - }, - "sortDirection": { - "type": "string", - "default": "desc", - "enum": ["asc", "desc"], - "description": "Sort direction for the events. Defaults to descending order of created at timestamp." - }, - "subject": { "type": "string", "format": "uri" }, - "includeAllUserRecords": { - "type": "boolean", - "default": false, - "description": "If true, events on all record types (posts, lists, profile etc.) owned by the did are returned" - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { "type": "string" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["events"], - "properties": { - "cursor": { "type": "string" }, - "events": { - "type": "array", - "items": { - "type": "ref", - "ref": "com.atproto.admin.defs#modEventView" - } - } - } - } - } - } - } -} diff --git a/lexicons/com/atproto/admin/updateAccountPassword.json b/lexicons/com/atproto/admin/updateAccountPassword.json new file mode 100644 index 00000000000..76c69fec0b6 --- /dev/null +++ b/lexicons/com/atproto/admin/updateAccountPassword.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.updateAccountPassword", + "defs": { + "main": { + "type": "procedure", + "description": "Update the password for a user account as an administrator.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did", "password"], + "properties": { + "did": { "type": "string", "format": "did" }, + "password": { "type": "string" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/identity/getRecommendedDidCredentials.json b/lexicons/com/atproto/identity/getRecommendedDidCredentials.json new file mode 100644 index 00000000000..3506dbec351 --- /dev/null +++ b/lexicons/com/atproto/identity/getRecommendedDidCredentials.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "com.atproto.identity.getRecommendedDidCredentials", + "defs": { + "main": { + "type": "query", + "description": "Describe the credentials that should be included in the DID doc of an account that is migrating to this service.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "rotationKeys": { + "description": "Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs.", + "type": "array", + "items": { "type": "string" } + }, + "alsoKnownAs": { + "type": "array", + "items": { "type": "string" } + }, + "verificationMethods": { "type": "unknown" }, + "services": { "type": "unknown" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/identity/requestPlcOperationSignature.json b/lexicons/com/atproto/identity/requestPlcOperationSignature.json new file mode 100644 index 00000000000..4aa8b18fee9 --- /dev/null +++ b/lexicons/com/atproto/identity/requestPlcOperationSignature.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "com.atproto.identity.requestPlcOperationSignature", + "defs": { + "main": { + "type": "procedure", + "description": "Request an email with a code to in order to request a signed PLC operation. Requires Auth." + } + } +} diff --git a/lexicons/com/atproto/identity/resolveHandle.json b/lexicons/com/atproto/identity/resolveHandle.json index ae5aab8f8fc..95885088a7b 100644 --- a/lexicons/com/atproto/identity/resolveHandle.json +++ b/lexicons/com/atproto/identity/resolveHandle.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Provides the DID of a repo.", + "description": "Resolves a handle (domain name) to a DID.", "parameters": { "type": "params", "required": ["handle"], diff --git a/lexicons/com/atproto/identity/signPlcOperation.json b/lexicons/com/atproto/identity/signPlcOperation.json new file mode 100644 index 00000000000..05a952cab6c --- /dev/null +++ b/lexicons/com/atproto/identity/signPlcOperation.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "com.atproto.identity.signPlcOperation", + "defs": { + "main": { + "type": "procedure", + "description": "Signs a PLC operation to update some value(s) in the requesting DID's document.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "token": { + "description": "A token received through com.atproto.identity.requestPlcOperationSignature", + "type": "string" + }, + "rotationKeys": { + "type": "array", + "items": { "type": "string" } + }, + "alsoKnownAs": { + "type": "array", + "items": { "type": "string" } + }, + "verificationMethods": { "type": "unknown" }, + "services": { "type": "unknown" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["operation"], + "properties": { + "operation": { + "type": "unknown", + "description": "A signed DID PLC operation." + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/identity/submitPlcOperation.json b/lexicons/com/atproto/identity/submitPlcOperation.json new file mode 100644 index 00000000000..280f5003b0d --- /dev/null +++ b/lexicons/com/atproto/identity/submitPlcOperation.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.identity.submitPlcOperation", + "defs": { + "main": { + "type": "procedure", + "description": "Validates a PLC operation to ensure that it doesn't violate a service's constraints or get the identity into a bad state, then submits it to the PLC registry", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["operation"], + "properties": { + "operation": { "type": "unknown" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/identity/updateHandle.json b/lexicons/com/atproto/identity/updateHandle.json index 5fe392bb838..9bb5c347d38 100644 --- a/lexicons/com/atproto/identity/updateHandle.json +++ b/lexicons/com/atproto/identity/updateHandle.json @@ -4,14 +4,18 @@ "defs": { "main": { "type": "procedure", - "description": "Updates the handle of the account.", + "description": "Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.", "input": { "encoding": "application/json", "schema": { "type": "object", "required": ["handle"], "properties": { - "handle": { "type": "string", "format": "handle" } + "handle": { + "type": "string", + "format": "handle", + "description": "The new handle." + } } } } diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json index 06e4e8f9cd2..9b1a1196e01 100644 --- a/lexicons/com/atproto/label/defs.json +++ b/lexicons/com/atproto/label/defs.json @@ -7,6 +7,10 @@ "description": "Metadata tag on an atproto resource (eg, repo or record).", "required": ["src", "uri", "val", "cts"], "properties": { + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + }, "src": { "type": "string", "format": "did", @@ -35,6 +39,15 @@ "type": "string", "format": "datetime", "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." } } }, @@ -61,6 +74,83 @@ "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"] + }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "locales": { + "type": "array", + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } + } + } + }, + "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/lexicons/com/atproto/label/queryLabels.json b/lexicons/com/atproto/label/queryLabels.json index 7b6fbe23d54..6c81cb0bba6 100644 --- a/lexicons/com/atproto/label/queryLabels.json +++ b/lexicons/com/atproto/label/queryLabels.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Find labels relevant to the provided URI patterns.", + "description": "Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.", "parameters": { "type": "params", "required": ["uriPatterns"], diff --git a/lexicons/com/atproto/label/subscribeLabels.json b/lexicons/com/atproto/label/subscribeLabels.json index 9813ffc192e..5fb1a852d3d 100644 --- a/lexicons/com/atproto/label/subscribeLabels.json +++ b/lexicons/com/atproto/label/subscribeLabels.json @@ -4,13 +4,13 @@ "defs": { "main": { "type": "subscription", - "description": "Subscribe to label updates.", + "description": "Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.", "parameters": { "type": "params", "properties": { "cursor": { "type": "integer", - "description": "The last known event to backfill from." + "description": "The last known event seq number to backfill from." } } }, diff --git a/lexicons/com/atproto/moderation/createReport.json b/lexicons/com/atproto/moderation/createReport.json index 161d622fcf2..f41d28d0b15 100644 --- a/lexicons/com/atproto/moderation/createReport.json +++ b/lexicons/com/atproto/moderation/createReport.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Report a repo or a record.", + "description": "Submit a moderation report regarding an atproto account or record. Implemented by moderation services (with PDS proxying), and requires auth.", "input": { "encoding": "application/json", "schema": { @@ -13,9 +13,13 @@ "properties": { "reasonType": { "type": "ref", + "description": "Indicates the broad category of violation the report is for.", "ref": "com.atproto.moderation.defs#reasonType" }, - "reason": { "type": "string" }, + "reason": { + "type": "string", + "description": "Additional context about the content and violation." + }, "subject": { "type": "union", "refs": [ diff --git a/lexicons/com/atproto/repo/applyWrites.json b/lexicons/com/atproto/repo/applyWrites.json index 050b6efbfab..427fc84c4a5 100644 --- a/lexicons/com/atproto/repo/applyWrites.json +++ b/lexicons/com/atproto/repo/applyWrites.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Apply a batch transaction of creates, updates, and deletes.", + "description": "Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.", "input": { "encoding": "application/json", "schema": { @@ -14,12 +14,12 @@ "repo": { "type": "string", "format": "at-identifier", - "description": "The handle or DID of the repo." + "description": "The handle or DID of the repo (aka, current account)." }, "validate": { "type": "boolean", "default": true, - "description": "Flag for validating the records." + "description": "Can be set to 'false' to skip Lexicon schema validation of record data, for all operations." }, "writes": { "type": "array", @@ -31,16 +31,22 @@ }, "swapCommit": { "type": "string", + "description": "If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.", "format": "cid" } } } }, - "errors": [{ "name": "InvalidSwap" }] + "errors": [ + { + "name": "InvalidSwap", + "description": "Indicates that the 'swapCommit' parameter did not match current commit." + } + ] }, "create": { "type": "object", - "description": "Create a new record.", + "description": "Operation which creates a new record.", "required": ["collection", "value"], "properties": { "collection": { "type": "string", "format": "nsid" }, @@ -50,7 +56,7 @@ }, "update": { "type": "object", - "description": "Update an existing record.", + "description": "Operation which updates an existing record.", "required": ["collection", "rkey", "value"], "properties": { "collection": { "type": "string", "format": "nsid" }, @@ -60,7 +66,7 @@ }, "delete": { "type": "object", - "description": "Delete an existing record.", + "description": "Operation which deletes an existing record.", "required": ["collection", "rkey"], "properties": { "collection": { "type": "string", "format": "nsid" }, diff --git a/lexicons/com/atproto/repo/createRecord.json b/lexicons/com/atproto/repo/createRecord.json index baef20c88f0..185f5250850 100644 --- a/lexicons/com/atproto/repo/createRecord.json +++ b/lexicons/com/atproto/repo/createRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Create a new record.", + "description": "Create a single new repository record. Requires auth, implemented by PDS.", "input": { "encoding": "application/json", "schema": { @@ -14,7 +14,7 @@ "repo": { "type": "string", "format": "at-identifier", - "description": "The handle or DID of the repo." + "description": "The handle or DID of the repo (aka, current account)." }, "collection": { "type": "string", @@ -23,17 +23,17 @@ }, "rkey": { "type": "string", - "description": "The key of the record.", + "description": "The Record Key.", "maxLength": 15 }, "validate": { "type": "boolean", "default": true, - "description": "Flag for validating the record." + "description": "Can be set to 'false' to skip Lexicon schema validation of record data." }, "record": { "type": "unknown", - "description": "The record to create." + "description": "The record itself. Must contain a $type field." }, "swapCommit": { "type": "string", @@ -54,7 +54,12 @@ } } }, - "errors": [{ "name": "InvalidSwap" }] + "errors": [ + { + "name": "InvalidSwap", + "description": "Indicates that 'swapCommit' didn't match current repo commit." + } + ] } } } diff --git a/lexicons/com/atproto/repo/deleteRecord.json b/lexicons/com/atproto/repo/deleteRecord.json index d8d7955b6a9..65b9f8f9536 100644 --- a/lexicons/com/atproto/repo/deleteRecord.json +++ b/lexicons/com/atproto/repo/deleteRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Delete a record, or ensure it doesn't exist.", + "description": "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", "input": { "encoding": "application/json", "schema": { @@ -14,7 +14,7 @@ "repo": { "type": "string", "format": "at-identifier", - "description": "The handle or DID of the repo." + "description": "The handle or DID of the repo (aka, current account)." }, "collection": { "type": "string", @@ -23,7 +23,7 @@ }, "rkey": { "type": "string", - "description": "The key of the record." + "description": "The Record Key." }, "swapRecord": { "type": "string", diff --git a/lexicons/com/atproto/repo/describeRepo.json b/lexicons/com/atproto/repo/describeRepo.json index b7f283bff70..b1ce2b6cf9e 100644 --- a/lexicons/com/atproto/repo/describeRepo.json +++ b/lexicons/com/atproto/repo/describeRepo.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get information about the repo, including the list of collections.", + "description": "Get information about an account and repository, including the list of collections. Does not require auth.", "parameters": { "type": "params", "required": ["repo"], @@ -30,12 +30,19 @@ "properties": { "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, - "didDoc": { "type": "unknown" }, + "didDoc": { + "type": "unknown", + "description": "The complete DID document for this account." + }, "collections": { "type": "array", + "description": "List of all the collections (NSIDs) for which this repo contains at least one record.", "items": { "type": "string", "format": "nsid" } }, - "handleIsCorrect": { "type": "boolean" } + "handleIsCorrect": { + "type": "boolean", + "description": "Indicates if handle is currently valid (resolves bi-directionally)" + } } } } diff --git a/lexicons/com/atproto/repo/getRecord.json b/lexicons/com/atproto/repo/getRecord.json index ec4d17e4260..5d8bb173470 100644 --- a/lexicons/com/atproto/repo/getRecord.json +++ b/lexicons/com/atproto/repo/getRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a record.", + "description": "Get a single record from a repository. Does not require auth.", "parameters": { "type": "params", "required": ["repo", "collection", "rkey"], @@ -19,7 +19,7 @@ "format": "nsid", "description": "The NSID of the record collection." }, - "rkey": { "type": "string", "description": "The key of the record." }, + "rkey": { "type": "string", "description": "The Record Key." }, "cid": { "type": "string", "format": "cid", diff --git a/lexicons/com/atproto/repo/importRepo.json b/lexicons/com/atproto/repo/importRepo.json new file mode 100644 index 00000000000..fc850b1a2b6 --- /dev/null +++ b/lexicons/com/atproto/repo/importRepo.json @@ -0,0 +1,13 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.importRepo", + "defs": { + "main": { + "type": "procedure", + "description": "Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.", + "input": { + "encoding": "application/vnd.ipld.car" + } + } + } +} diff --git a/lexicons/com/atproto/repo/listMissingBlobs.json b/lexicons/com/atproto/repo/listMissingBlobs.json new file mode 100644 index 00000000000..c39913d566c --- /dev/null +++ b/lexicons/com/atproto/repo/listMissingBlobs.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.listMissingBlobs", + "defs": { + "main": { + "type": "query", + "description": "Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 500 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["blobs"], + "properties": { + "cursor": { "type": "string" }, + "blobs": { + "type": "array", + "items": { "type": "ref", "ref": "#recordBlob" } + } + } + } + } + }, + "recordBlob": { + "type": "object", + "required": ["cid", "recordUri"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "recordUri": { "type": "string", "format": "at-uri" } + } + } + } +} diff --git a/lexicons/com/atproto/repo/listRecords.json b/lexicons/com/atproto/repo/listRecords.json index ac04e3e8782..bc91c952bb1 100644 --- a/lexicons/com/atproto/repo/listRecords.json +++ b/lexicons/com/atproto/repo/listRecords.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "List a range of records in a collection.", + "description": "List a range of records in a repository, matching a specific collection. Does not require auth.", "parameters": { "type": "params", "required": ["repo", "collection"], diff --git a/lexicons/com/atproto/repo/putRecord.json b/lexicons/com/atproto/repo/putRecord.json index ae39bd95ead..51f11c0f13f 100644 --- a/lexicons/com/atproto/repo/putRecord.json +++ b/lexicons/com/atproto/repo/putRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Write a record, creating or updating it as needed.", + "description": "Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.", "input": { "encoding": "application/json", "schema": { @@ -15,7 +15,7 @@ "repo": { "type": "string", "format": "at-identifier", - "description": "The handle or DID of the repo." + "description": "The handle or DID of the repo (aka, current account)." }, "collection": { "type": "string", @@ -24,13 +24,13 @@ }, "rkey": { "type": "string", - "description": "The key of the record.", + "description": "The Record Key.", "maxLength": 15 }, "validate": { "type": "boolean", "default": true, - "description": "Flag for validating the record." + "description": "Can be set to 'false' to skip Lexicon schema validation of record data." }, "record": { "type": "unknown", @@ -39,7 +39,7 @@ "swapRecord": { "type": "string", "format": "cid", - "description": "Compare and swap with the previous record by CID." + "description": "Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation" }, "swapCommit": { "type": "string", diff --git a/lexicons/com/atproto/repo/uploadBlob.json b/lexicons/com/atproto/repo/uploadBlob.json index 63d1671bd3e..547a995a051 100644 --- a/lexicons/com/atproto/repo/uploadBlob.json +++ b/lexicons/com/atproto/repo/uploadBlob.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Upload a new blob to be added to repo in a later request.", + "description": "Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.", "input": { "encoding": "*/*" }, diff --git a/lexicons/com/atproto/server/activateAccount.json b/lexicons/com/atproto/server/activateAccount.json new file mode 100644 index 00000000000..e06935fcd07 --- /dev/null +++ b/lexicons/com/atproto/server/activateAccount.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.activateAccount", + "defs": { + "main": { + "type": "procedure", + "description": "Activates a currently deactivated account. Used to finalize account migration after the account's repo is imported and identity is setup." + } + } +} diff --git a/lexicons/com/atproto/server/checkAccountStatus.json b/lexicons/com/atproto/server/checkAccountStatus.json new file mode 100644 index 00000000000..d34596e60e1 --- /dev/null +++ b/lexicons/com/atproto/server/checkAccountStatus.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.checkAccountStatus", + "defs": { + "main": { + "type": "query", + "description": "Returns the status of an account, especially as pertaining to import or recovery. Can be called many times over the course of an account migration. Requires auth and can only be called pertaining to oneself.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "activated", + "validDid", + "repoCommit", + "repoRev", + "repoBlocks", + "indexedRecords", + "privateStateValues", + "expectedBlobs", + "importedBlobs" + ], + "properties": { + "activated": { "type": "boolean" }, + "validDid": { "type": "boolean" }, + "repoCommit": { "type": "string", "format": "cid" }, + "repoRev": { "type": "string" }, + "repoBlocks": { "type": "integer" }, + "indexedRecords": { "type": "integer" }, + "privateStateValues": { "type": "integer" }, + "expectedBlobs": { "type": "integer" }, + "importedBlobs": { "type": "integer" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index d1456e095ae..b32bbe1569d 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Create an account.", + "description": "Create an account. Implemented by PDS.", "input": { "encoding": "application/json", "schema": { @@ -12,14 +12,31 @@ "required": ["handle"], "properties": { "email": { "type": "string" }, - "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" }, + "handle": { + "type": "string", + "format": "handle", + "description": "Requested handle for the account." + }, + "did": { + "type": "string", + "format": "did", + "description": "Pre-existing atproto DID, being imported to a new account." + }, "inviteCode": { "type": "string" }, "verificationCode": { "type": "string" }, "verificationPhone": { "type": "string" }, - "password": { "type": "string" }, - "recoveryKey": { "type": "string" }, - "plcOp": { "type": "unknown" } + "password": { + "type": "string", + "description": "Initial account password. May need to meet instance-specific password strength requirements." + }, + "recoveryKey": { + "type": "string", + "description": "DID PLC rotation key (aka, recovery key) to be included in PLC creation operation." + }, + "plcOp": { + "type": "unknown", + "description": "A signed DID PLC operation to be submitted as part of importing an existing account to this instance. NOTE: this optional field may be updated when full account migration is implemented." + } } } }, @@ -27,13 +44,21 @@ "encoding": "application/json", "schema": { "type": "object", + "description": "Account login session returned on successful account creation.", "required": ["accessJwt", "refreshJwt", "handle", "did"], "properties": { "accessJwt": { "type": "string" }, "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" }, - "didDoc": { "type": "unknown" } + "did": { + "type": "string", + "format": "did", + "description": "The DID of the new account." + }, + "didDoc": { + "type": "unknown", + "description": "Complete DID document." + } } } }, diff --git a/lexicons/com/atproto/server/createAppPassword.json b/lexicons/com/atproto/server/createAppPassword.json index f12e8e2557e..0a60e4e30b0 100644 --- a/lexicons/com/atproto/server/createAppPassword.json +++ b/lexicons/com/atproto/server/createAppPassword.json @@ -11,7 +11,10 @@ "type": "object", "required": ["name"], "properties": { - "name": { "type": "string" } + "name": { + "type": "string", + "description": "A short name for the App Password, to help distinguish them." + } } } }, diff --git a/lexicons/com/atproto/server/deactivateAccount.json b/lexicons/com/atproto/server/deactivateAccount.json new file mode 100644 index 00000000000..698fccf202e --- /dev/null +++ b/lexicons/com/atproto/server/deactivateAccount.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.deactivateAccount", + "defs": { + "main": { + "type": "procedure", + "description": "Deactivates a currently active account. Stops serving of repo, and future writes to repo until reactivated. Used to finalize account migration with the old host after the account has been activated on the new host.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "deleteAfter": { + "type": "string", + "format": "datetime", + "description": "A recommendation to server as to how long they should hold onto the deactivated account before deleting." + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/deleteAccount.json b/lexicons/com/atproto/server/deleteAccount.json index 3747189dca3..cf4babfe7da 100644 --- a/lexicons/com/atproto/server/deleteAccount.json +++ b/lexicons/com/atproto/server/deleteAccount.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Delete an actor's account with a token and password.", + "description": "Delete an actor's account with a token and password. Can only be called after requesting a deletion token. Requires auth.", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/com/atproto/server/deleteSession.json b/lexicons/com/atproto/server/deleteSession.json index e05d019024a..807a89dc9bd 100644 --- a/lexicons/com/atproto/server/deleteSession.json +++ b/lexicons/com/atproto/server/deleteSession.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Delete the current session." + "description": "Delete the current session. Requires auth." } } } diff --git a/lexicons/com/atproto/server/describeServer.json b/lexicons/com/atproto/server/describeServer.json index 3c60a58ecaf..3eab73e0680 100644 --- a/lexicons/com/atproto/server/describeServer.json +++ b/lexicons/com/atproto/server/describeServer.json @@ -4,20 +4,40 @@ "defs": { "main": { "type": "query", - "description": "Get a document describing the service's accounts configuration.", + "description": "Describes the server's account creation requirements and capabilities. Implemented by PDS.", "output": { "encoding": "application/json", "schema": { "type": "object", - "required": ["availableUserDomains"], + "required": ["did", "availableUserDomains"], "properties": { - "inviteCodeRequired": { "type": "boolean" }, - "phoneVerificationRequired": { "type": "boolean" }, + "inviteCodeRequired": { + "type": "boolean", + "description": "If true, an invite code must be supplied to create an account on this instance." + }, + "phoneVerificationRequired": { + "type": "boolean", + "description": "If true, a phone verification token must be supplied to create an account on this instance." + }, "availableUserDomains": { "type": "array", + "description": "List of domain suffixes that can be used in account handles.", "items": { "type": "string" } }, - "links": { "type": "ref", "ref": "#links" } + "links": { + "type": "ref", + "description": "URLs of service policy documents.", + "ref": "#links" + }, + "contact": { + "type": "ref", + "description": "Contact information", + "ref": "#contact" + }, + "did": { + "type": "string", + "format": "did" + } } } } @@ -28,6 +48,12 @@ "privacyPolicy": { "type": "string" }, "termsOfService": { "type": "string" } } + }, + "contact": { + "type": "object", + "properties": { + "email": { "type": "string" } + } } } } diff --git a/lexicons/com/atproto/server/getAccountInviteCodes.json b/lexicons/com/atproto/server/getAccountInviteCodes.json index ac23b11f23f..72f0822703d 100644 --- a/lexicons/com/atproto/server/getAccountInviteCodes.json +++ b/lexicons/com/atproto/server/getAccountInviteCodes.json @@ -4,12 +4,16 @@ "defs": { "main": { "type": "query", - "description": "Get all invite codes for a given account.", + "description": "Get all invite codes for the current account. Requires auth.", "parameters": { "type": "params", "properties": { "includeUsed": { "type": "boolean", "default": true }, - "createAvailable": { "type": "boolean", "default": true } + "createAvailable": { + "type": "boolean", + "default": true, + "description": "Controls whether any new 'earned' but not 'created' invites should be created." + } } }, "output": { diff --git a/lexicons/com/atproto/server/getServiceAuth.json b/lexicons/com/atproto/server/getServiceAuth.json new file mode 100644 index 00000000000..95984c186ad --- /dev/null +++ b/lexicons/com/atproto/server/getServiceAuth.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.getServiceAuth", + "defs": { + "main": { + "type": "query", + "description": "Get a signed token on behalf of the requesting DID for the requested service.", + "parameters": { + "type": "params", + "required": ["aud"], + "properties": { + "aud": { + "type": "string", + "format": "did", + "description": "The DID of the service that the token will be used to authenticate with" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token"], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/getSession.json b/lexicons/com/atproto/server/getSession.json index 5f7700882da..6b5f280e746 100644 --- a/lexicons/com/atproto/server/getSession.json +++ b/lexicons/com/atproto/server/getSession.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get information about the current session.", + "description": "Get information about the current auth session. Requires auth.", "output": { "encoding": "application/json", "schema": { diff --git a/lexicons/com/atproto/server/refreshSession.json b/lexicons/com/atproto/server/refreshSession.json index 3f4d7fdf272..0b067f86b7f 100644 --- a/lexicons/com/atproto/server/refreshSession.json +++ b/lexicons/com/atproto/server/refreshSession.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Refresh an authentication session.", + "description": "Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').", "output": { "encoding": "application/json", "schema": { diff --git a/lexicons/com/atproto/server/reserveSigningKey.json b/lexicons/com/atproto/server/reserveSigningKey.json index 3a67ad0a3c8..a33e1ede68e 100644 --- a/lexicons/com/atproto/server/reserveSigningKey.json +++ b/lexicons/com/atproto/server/reserveSigningKey.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Reserve a repo signing key for account creation.", + "description": "Reserve a repo signing key, for use with account creation. Necessary so that a DID PLC update operation can be constructed during an account migraiton. Public and does not require auth; implemented by PDS. NOTE: this endpoint may change when full account migration is implemented.", "input": { "encoding": "application/json", "schema": { @@ -12,7 +12,8 @@ "properties": { "did": { "type": "string", - "description": "The did to reserve a new did:key for" + "format": "did", + "description": "The DID to reserve a key for." } } } @@ -25,7 +26,7 @@ "properties": { "signingKey": { "type": "string", - "description": "Public signing key in the form of a did:key." + "description": "The public key for the reserved signing key, in did:key serialization." } } } diff --git a/lexicons/com/atproto/sync/getBlob.json b/lexicons/com/atproto/sync/getBlob.json index 23e18a4f3b5..57ece7a9d8a 100644 --- a/lexicons/com/atproto/sync/getBlob.json +++ b/lexicons/com/atproto/sync/getBlob.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get a blob associated with a given repo.", + "description": "Get a blob associated with a given account. Returns the full blob as originally uploaded. Does not require auth; implemented by PDS.", "parameters": { "type": "params", "required": ["did", "cid"], @@ -12,7 +12,7 @@ "did": { "type": "string", "format": "did", - "description": "The DID of the repo." + "description": "The DID of the account." }, "cid": { "type": "string", diff --git a/lexicons/com/atproto/sync/getBlocks.json b/lexicons/com/atproto/sync/getBlocks.json index cf776a0c88f..29dd4971904 100644 --- a/lexicons/com/atproto/sync/getBlocks.json +++ b/lexicons/com/atproto/sync/getBlocks.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get blocks from a given repo.", + "description": "Get data blocks from a given repo, by CID. For example, intermediate MST nodes, or records. Does not require auth; implemented by PDS.", "parameters": { "type": "params", "required": ["did", "cids"], diff --git a/lexicons/com/atproto/sync/getLatestCommit.json b/lexicons/com/atproto/sync/getLatestCommit.json index d8754f09062..ac7faf57570 100644 --- a/lexicons/com/atproto/sync/getLatestCommit.json +++ b/lexicons/com/atproto/sync/getLatestCommit.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get the current commit CID & revision of the repo.", + "description": "Get the current commit CID & revision of the specified repo. Does not require auth.", "parameters": { "type": "params", "required": ["did"], diff --git a/lexicons/com/atproto/sync/getRecord.json b/lexicons/com/atproto/sync/getRecord.json index cbd0ad3a5ac..718245a5195 100644 --- a/lexicons/com/atproto/sync/getRecord.json +++ b/lexicons/com/atproto/sync/getRecord.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get blocks needed for existence or non-existence of record.", + "description": "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", "parameters": { "type": "params", "required": ["did", "collection", "rkey"], @@ -15,7 +15,7 @@ "description": "The DID of the repo." }, "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string" }, + "rkey": { "type": "string", "description": "Record Key" }, "commit": { "type": "string", "format": "cid", diff --git a/lexicons/com/atproto/sync/getRepo.json b/lexicons/com/atproto/sync/getRepo.json index fb68ab670ee..7fa710abfb5 100644 --- a/lexicons/com/atproto/sync/getRepo.json +++ b/lexicons/com/atproto/sync/getRepo.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Gets the DID's repo, optionally catching up from a specific revision.", + "description": "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", "parameters": { "type": "params", "required": ["did"], @@ -16,7 +16,7 @@ }, "since": { "type": "string", - "description": "The revision of the repo to catch up from." + "description": "The revision ('rev') of the repo to create a diff from." } } }, diff --git a/lexicons/com/atproto/sync/listBlobs.json b/lexicons/com/atproto/sync/listBlobs.json index 46815eeb49a..b4c954d999a 100644 --- a/lexicons/com/atproto/sync/listBlobs.json +++ b/lexicons/com/atproto/sync/listBlobs.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "List blob CIDs since some revision.", + "description": "List blob CIDso for an account, since some repo revision. Does not require auth; implemented by PDS.", "parameters": { "type": "params", "required": ["did"], diff --git a/lexicons/com/atproto/sync/listRepos.json b/lexicons/com/atproto/sync/listRepos.json index 440e8693d5e..07ae35e2c5e 100644 --- a/lexicons/com/atproto/sync/listRepos.json +++ b/lexicons/com/atproto/sync/listRepos.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "List DIDs and root CIDs of hosted repos.", + "description": "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.", "parameters": { "type": "params", "properties": { @@ -37,7 +37,11 @@ "required": ["did", "head", "rev"], "properties": { "did": { "type": "string", "format": "did" }, - "head": { "type": "string", "format": "cid" }, + "head": { + "type": "string", + "format": "cid", + "description": "Current repo commit CID" + }, "rev": { "type": "string" } } } diff --git a/lexicons/com/atproto/sync/notifyOfUpdate.json b/lexicons/com/atproto/sync/notifyOfUpdate.json index 48cb4b24678..034a9655a08 100644 --- a/lexicons/com/atproto/sync/notifyOfUpdate.json +++ b/lexicons/com/atproto/sync/notifyOfUpdate.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Notify a crawling service of a recent update; often when a long break between updates causes the connection with the crawling service to break.", + "description": "Notify a crawling service of a recent update, and that crawling should resume. Intended use is after a gap between repo stream events caused the crawling service to disconnect. Does not require auth; implemented by Relay.", "input": { "encoding": "application/json", "schema": { @@ -13,7 +13,7 @@ "properties": { "hostname": { "type": "string", - "description": "Hostname of the service that is notifying of update." + "description": "Hostname of the current service (usually a PDS) that is notifying of update." } } } diff --git a/lexicons/com/atproto/sync/requestCrawl.json b/lexicons/com/atproto/sync/requestCrawl.json index a3520a33180..8e075a376fc 100644 --- a/lexicons/com/atproto/sync/requestCrawl.json +++ b/lexicons/com/atproto/sync/requestCrawl.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Request a service to persistently crawl hosted repos.", + "description": "Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.", "input": { "encoding": "application/json", "schema": { @@ -13,7 +13,7 @@ "properties": { "hostname": { "type": "string", - "description": "Hostname of the service that is requesting to be crawled." + "description": "Hostname of the current service (eg, PDS) that is requesting to be crawled." } } } diff --git a/lexicons/com/atproto/sync/subscribeRepos.json b/lexicons/com/atproto/sync/subscribeRepos.json index 9a5c0f6153c..31d68b91c07 100644 --- a/lexicons/com/atproto/sync/subscribeRepos.json +++ b/lexicons/com/atproto/sync/subscribeRepos.json @@ -4,26 +4,40 @@ "defs": { "main": { "type": "subscription", - "description": "Subscribe to repo updates.", + "description": "Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.", "parameters": { "type": "params", "properties": { "cursor": { "type": "integer", - "description": "The last known event to backfill from." + "description": "The last known event seq number to backfill from." } } }, "message": { "schema": { "type": "union", - "refs": ["#commit", "#handle", "#migrate", "#tombstone", "#info"] + "refs": [ + "#commit", + "#identity", + "#handle", + "#migrate", + "#tombstone", + "#info" + ] } }, - "errors": [{ "name": "FutureCursor" }, { "name": "ConsumerTooSlow" }] + "errors": [ + { "name": "FutureCursor" }, + { + "name": "ConsumerTooSlow", + "description": "If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection." + } + ] }, "commit": { "type": "object", + "description": "Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature.", "required": [ "seq", "rebase", @@ -39,39 +53,77 @@ ], "nullable": ["prev", "since"], "properties": { - "seq": { "type": "integer" }, - "rebase": { "type": "boolean" }, - "tooBig": { "type": "boolean" }, - "repo": { "type": "string", "format": "did" }, - "commit": { "type": "cid-link" }, - "prev": { "type": "cid-link" }, + "seq": { + "type": "integer", + "description": "The stream sequence number of this message." + }, + "rebase": { "type": "boolean", "description": "DEPRECATED -- unused" }, + "tooBig": { + "type": "boolean", + "description": "Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data." + }, + "repo": { + "type": "string", + "format": "did", + "description": "The repo this event comes from." + }, + "commit": { + "type": "cid-link", + "description": "Repo commit object CID." + }, + "prev": { + "type": "cid-link", + "description": "DEPRECATED -- unused. WARNING -- nullable and optional; stick with optional to ensure golang interoperability." + }, "rev": { "type": "string", - "description": "The rev of the emitted commit." + "description": "The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event." }, "since": { "type": "string", - "description": "The rev of the last emitted commit from this repo." + "description": "The rev of the last emitted commit from this repo (if any)." }, "blocks": { "type": "bytes", - "description": "CAR file containing relevant blocks.", + "description": "CAR file containing relevant blocks, as a diff since the previous repo state.", "maxLength": 1000000 }, "ops": { "type": "array", - "items": { "type": "ref", "ref": "#repoOp" }, + "items": { + "type": "ref", + "ref": "#repoOp", + "description": "List of repo mutation operations in this commit (eg, records created, updated, or deleted)." + }, "maxLength": 200 }, "blobs": { "type": "array", - "items": { "type": "cid-link" } + "items": { + "type": "cid-link", + "description": "List of new blobs (by CID) referenced by records in this commit." + } }, + "time": { + "type": "string", + "format": "datetime", + "description": "Timestamp of when this message was originally broadcast." + } + } + }, + "identity": { + "type": "object", + "description": "Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache.", + "required": ["seq", "did", "time"], + "properties": { + "seq": { "type": "integer" }, + "did": { "type": "string", "format": "did" }, "time": { "type": "string", "format": "datetime" } } }, "handle": { "type": "object", + "description": "Represents an update of the account's handle, or transition to/from invalid state. NOTE: Will be deprecated in favor of #identity.", "required": ["seq", "did", "handle", "time"], "properties": { "seq": { "type": "integer" }, @@ -82,6 +134,7 @@ }, "migrate": { "type": "object", + "description": "Represents an account moving from one PDS instance to another. NOTE: not implemented; account migration uses #identity instead", "required": ["seq", "did", "migrateTo", "time"], "nullable": ["migrateTo"], "properties": { @@ -93,6 +146,7 @@ }, "tombstone": { "type": "object", + "description": "Indicates that an account has been deleted. NOTE: may be deprecated in favor of #identity or a future #account event", "required": ["seq", "did", "time"], "properties": { "seq": { "type": "integer" }, @@ -115,7 +169,7 @@ }, "repoOp": { "type": "object", - "description": "A repo operation, ie a write of a single record. For creates and updates, CID is the record's CID as of this operation. For deletes, it's null.", + "description": "A repo operation, ie a mutation of a single record.", "required": ["action", "path", "cid"], "nullable": ["cid"], "properties": { @@ -124,7 +178,10 @@ "knownValues": ["create", "update", "delete"] }, "path": { "type": "string" }, - "cid": { "type": "cid-link" } + "cid": { + "type": "cid-link", + "description": "For creates and updates, the new record CID. For deletions, null." + } } } } diff --git a/lexicons/com/atproto/temp/fetchLabels.json b/lexicons/com/atproto/temp/fetchLabels.json index 14e392fd5e7..57c3f732cdb 100644 --- a/lexicons/com/atproto/temp/fetchLabels.json +++ b/lexicons/com/atproto/temp/fetchLabels.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Fetch all labels from a labeler created after a certain date.", + "description": "DEPRECATED: use queryLabels or subscribeLabels instead -- Fetch all labels from a labeler created after a certain date.", "parameters": { "type": "params", "properties": { diff --git a/lexicons/com/atproto/temp/importRepo.json b/lexicons/com/atproto/temp/importRepo.json deleted file mode 100644 index f06daa09d73..00000000000 --- a/lexicons/com/atproto/temp/importRepo.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.importRepo", - "defs": { - "main": { - "type": "procedure", - "description": "Gets the did's repo, optionally catching up from a specific revision.", - "parameters": { - "type": "params", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "The DID of the repo." - } - } - }, - "input": { - "encoding": "application/vnd.ipld.car" - }, - "output": { - "encoding": "text/plain" - } - } - } -} diff --git a/lexicons/com/atproto/temp/pushBlob.json b/lexicons/com/atproto/temp/pushBlob.json deleted file mode 100644 index 9babc8f8e43..00000000000 --- a/lexicons/com/atproto/temp/pushBlob.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.pushBlob", - "defs": { - "main": { - "type": "procedure", - "description": "Gets the did's repo, optionally catching up from a specific revision.", - "parameters": { - "type": "params", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "The DID of the repo." - } - } - }, - "input": { - "encoding": "*/*" - } - } - } -} diff --git a/lexicons/com/atproto/temp/transferAccount.json b/lexicons/com/atproto/temp/transferAccount.json deleted file mode 100644 index 3cb2035ac0e..00000000000 --- a/lexicons/com/atproto/temp/transferAccount.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.transferAccount", - "defs": { - "main": { - "type": "procedure", - "description": "Transfer an account.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["handle", "did", "plcOp"], - "properties": { - "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" }, - "plcOp": { "type": "unknown" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["accessJwt", "refreshJwt", "handle", "did"], - "properties": { - "accessJwt": { "type": "string" }, - "refreshJwt": { "type": "string" }, - "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" } - } - } - }, - "errors": [ - { "name": "InvalidHandle" }, - { "name": "InvalidPassword" }, - { "name": "InvalidInviteCode" }, - { "name": "HandleNotAvailable" }, - { "name": "UnsupportedDomain" }, - { "name": "UnresolvableDid" }, - { "name": "IncompatibleDidDoc" } - ] - } - } -} diff --git a/lexicons/com/atproto/admin/createCommunicationTemplate.json b/lexicons/tools/ozone/communication/createTemplate.json similarity index 90% rename from lexicons/com/atproto/admin/createCommunicationTemplate.json rename to lexicons/tools/ozone/communication/createTemplate.json index d4546a0e213..175a55f9d03 100644 --- a/lexicons/com/atproto/admin/createCommunicationTemplate.json +++ b/lexicons/tools/ozone/communication/createTemplate.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.createCommunicationTemplate", + "id": "tools.ozone.communication.createTemplate", "defs": { "main": { "type": "procedure", @@ -35,7 +35,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#communicationTemplateView" + "ref": "tools.ozone.communication.defs#templateView" } } } diff --git a/lexicons/tools/ozone/communication/defs.json b/lexicons/tools/ozone/communication/defs.json new file mode 100644 index 00000000000..89d28bdf690 --- /dev/null +++ b/lexicons/tools/ozone/communication/defs.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "tools.ozone.communication.defs", + "defs": { + "templateView": { + "type": "object", + "required": [ + "id", + "name", + "contentMarkdown", + "disabled", + "lastUpdatedBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string", "description": "Name of the template." }, + "subject": { + "type": "string", + "description": "Content of the template, can contain markdown and variable placeholders." + }, + "contentMarkdown": { + "type": "string", + "description": "Subject of the message, used in emails." + }, + "disabled": { "type": "boolean" }, + "lastUpdatedBy": { + "type": "string", + "format": "did", + "description": "DID of the user who last updated the template." + }, + "createdAt": { "type": "string", "format": "datetime" }, + "updatedAt": { "type": "string", "format": "datetime" } + } + } + } +} diff --git a/lexicons/com/atproto/admin/deleteCommunicationTemplate.json b/lexicons/tools/ozone/communication/deleteTemplate.json similarity index 86% rename from lexicons/com/atproto/admin/deleteCommunicationTemplate.json rename to lexicons/tools/ozone/communication/deleteTemplate.json index 58861757c06..c85bd34959b 100644 --- a/lexicons/com/atproto/admin/deleteCommunicationTemplate.json +++ b/lexicons/tools/ozone/communication/deleteTemplate.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.deleteCommunicationTemplate", + "id": "tools.ozone.communication.deleteTemplate", "defs": { "main": { "type": "procedure", diff --git a/lexicons/com/atproto/admin/listCommunicationTemplates.json b/lexicons/tools/ozone/communication/listTemplates.json similarity index 79% rename from lexicons/com/atproto/admin/listCommunicationTemplates.json rename to lexicons/tools/ozone/communication/listTemplates.json index 74d5a399f15..65a24ed7359 100644 --- a/lexicons/com/atproto/admin/listCommunicationTemplates.json +++ b/lexicons/tools/ozone/communication/listTemplates.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.listCommunicationTemplates", + "id": "tools.ozone.communication.listTemplates", "defs": { "main": { "type": "query", @@ -15,7 +15,7 @@ "type": "array", "items": { "type": "ref", - "ref": "com.atproto.admin.defs#communicationTemplateView" + "ref": "tools.ozone.communication.defs#templateView" } } } diff --git a/lexicons/com/atproto/admin/updateCommunicationTemplate.json b/lexicons/tools/ozone/communication/updateTemplate.json similarity index 91% rename from lexicons/com/atproto/admin/updateCommunicationTemplate.json rename to lexicons/tools/ozone/communication/updateTemplate.json index 0f7f3612000..153453a875e 100644 --- a/lexicons/com/atproto/admin/updateCommunicationTemplate.json +++ b/lexicons/tools/ozone/communication/updateTemplate.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.updateCommunicationTemplate", + "id": "tools.ozone.communication.updateTemplate", "defs": { "main": { "type": "procedure", @@ -42,7 +42,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#communicationTemplateView" + "ref": "tools.ozone.communication.defs#templateView" } } } diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json new file mode 100644 index 00000000000..e88ac98303e --- /dev/null +++ b/lexicons/tools/ozone/moderation/defs.json @@ -0,0 +1,524 @@ +{ + "lexicon": 1, + "id": "tools.ozone.moderation.defs", + "defs": { + "modEventView": { + "type": "object", + "required": [ + "id", + "event", + "subject", + "subjectBlobCids", + "createdBy", + "createdAt" + ], + "properties": { + "id": { "type": "integer" }, + "event": { + "type": "union", + "refs": [ + "#modEventTakedown", + "#modEventReverseTakedown", + "#modEventComment", + "#modEventReport", + "#modEventLabel", + "#modEventAcknowledge", + "#modEventEscalate", + "#modEventMute", + "#modEventEmail", + "#modEventResolveAppeal", + "#modEventDivert" + ] + }, + "subject": { + "type": "union", + "refs": [ + "com.atproto.admin.defs#repoRef", + "com.atproto.repo.strongRef" + ] + }, + "subjectBlobCids": { "type": "array", "items": { "type": "string" } }, + "createdBy": { "type": "string", "format": "did" }, + "createdAt": { "type": "string", "format": "datetime" }, + "creatorHandle": { "type": "string" }, + "subjectHandle": { "type": "string" } + } + }, + "modEventViewDetail": { + "type": "object", + "required": [ + "id", + "event", + "subject", + "subjectBlobs", + "createdBy", + "createdAt" + ], + "properties": { + "id": { "type": "integer" }, + "event": { + "type": "union", + "refs": [ + "#modEventTakedown", + "#modEventReverseTakedown", + "#modEventComment", + "#modEventReport", + "#modEventLabel", + "#modEventAcknowledge", + "#modEventEscalate", + "#modEventMute", + "#modEventEmail", + "#modEventResolveAppeal", + "#modEventDivert" + ] + }, + "subject": { + "type": "union", + "refs": [ + "#repoView", + "#repoViewNotFound", + "#recordView", + "#recordViewNotFound" + ] + }, + "subjectBlobs": { + "type": "array", + "items": { "type": "ref", "ref": "#blobView" } + }, + "createdBy": { "type": "string", "format": "did" }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + "subjectStatusView": { + "type": "object", + "required": ["id", "subject", "createdAt", "updatedAt", "reviewState"], + "properties": { + "id": { "type": "integer" }, + "subject": { + "type": "union", + "refs": [ + "com.atproto.admin.defs#repoRef", + "com.atproto.repo.strongRef" + ] + }, + "subjectBlobCids": { + "type": "array", + "items": { "type": "string", "format": "cid" } + }, + "subjectRepoHandle": { "type": "string" }, + "updatedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp referencing when the last update was made to the moderation status of the subject" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp referencing the first moderation status impacting event was emitted on the subject" + }, + "reviewState": { + "type": "ref", + "ref": "#subjectReviewState" + }, + "comment": { + "type": "string", + "description": "Sticky comment on the subject." + }, + "muteUntil": { + "type": "string", + "format": "datetime" + }, + "lastReviewedBy": { + "type": "string", + "format": "did" + }, + "lastReviewedAt": { + "type": "string", + "format": "datetime" + }, + "lastReportedAt": { + "type": "string", + "format": "datetime" + }, + "lastAppealedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp referencing when the author of the subject appealed a moderation action" + }, + "takendown": { + "type": "boolean" + }, + "appealed": { + "type": "boolean", + "description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." + }, + "suspendUntil": { + "type": "string", + "format": "datetime" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "subjectReviewState": { + "type": "string", + "knownValues": [ + "#reviewOpen", + "#reviewEscalated", + "#reviewClosed", + "#reviewNone" + ] + }, + "reviewOpen": { + "type": "token", + "description": "Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator" + }, + "reviewEscalated": { + "type": "token", + "description": "Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator" + }, + "reviewClosed": { + "type": "token", + "description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator" + }, + "reviewNone": { + "type": "token", + "description": "Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it" + }, + "modEventTakedown": { + "type": "object", + "description": "Take down a subject permanently or temporarily", + "properties": { + "comment": { + "type": "string" + }, + "durationInHours": { + "type": "integer", + "description": "Indicates how long the takedown should be in effect before automatically expiring." + } + } + }, + "modEventReverseTakedown": { + "type": "object", + "description": "Revert take down action on a subject", + "properties": { + "comment": { + "type": "string", + "description": "Describe reasoning behind the reversal." + } + } + }, + "modEventResolveAppeal": { + "type": "object", + "description": "Resolve appeal on a subject", + "properties": { + "comment": { + "type": "string", + "description": "Describe resolution." + } + } + }, + "modEventComment": { + "type": "object", + "description": "Add a comment to a subject", + "required": ["comment"], + "properties": { + "comment": { + "type": "string" + }, + "sticky": { + "type": "boolean", + "description": "Make the comment persistent on the subject" + } + } + }, + "modEventReport": { + "type": "object", + "description": "Report a subject", + "required": ["reportType"], + "properties": { + "comment": { + "type": "string" + }, + "reportType": { + "type": "ref", + "ref": "com.atproto.moderation.defs#reasonType" + } + } + }, + "modEventLabel": { + "type": "object", + "description": "Apply/Negate labels on a subject", + "required": ["createLabelVals", "negateLabelVals"], + "properties": { + "comment": { + "type": "string" + }, + "createLabelVals": { + "type": "array", + "items": { "type": "string" } + }, + "negateLabelVals": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "modEventAcknowledge": { + "type": "object", + "properties": { + "comment": { "type": "string" } + } + }, + "modEventEscalate": { + "type": "object", + "properties": { + "comment": { "type": "string" } + } + }, + "modEventMute": { + "type": "object", + "description": "Mute incoming reports on a subject", + "required": ["durationInHours"], + "properties": { + "comment": { "type": "string" }, + "durationInHours": { + "type": "integer", + "description": "Indicates how long the subject should remain muted." + } + } + }, + "modEventUnmute": { + "type": "object", + "description": "Unmute action on a subject", + "properties": { + "comment": { + "type": "string", + "description": "Describe reasoning behind the reversal." + } + } + }, + "modEventEmail": { + "type": "object", + "description": "Keep a log of outgoing email to a user", + "required": ["subjectLine"], + "properties": { + "subjectLine": { + "type": "string", + "description": "The subject line of the email sent to the user." + }, + "content": { + "type": "string", + "description": "The content of the email sent to the user." + }, + "comment": { + "type": "string", + "description": "Additional comment about the outgoing comm." + } + } + }, + "modEventDivert": { + "type": "object", + "description": "Divert a record's blobs to a 3rd party service for further scanning/tagging", + "properties": { + "comment": { "type": "string" } + } + }, + "modEventTag": { + "type": "object", + "description": "Add/Remove a tag on a subject", + "required": ["add", "remove"], + "properties": { + "add": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags to be added to the subject. If already exists, won't be duplicated." + }, + "remove": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated." + }, + "comment": { + "type": "string", + "description": "Additional comment about added/removed tags." + } + } + }, + "repoView": { + "type": "object", + "required": [ + "did", + "handle", + "relatedRecords", + "indexedAt", + "moderation" + ], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "email": { "type": "string" }, + "relatedRecords": { "type": "array", "items": { "type": "unknown" } }, + "indexedAt": { "type": "string", "format": "datetime" }, + "moderation": { "type": "ref", "ref": "#moderation" }, + "invitedBy": { + "type": "ref", + "ref": "com.atproto.server.defs#inviteCode" + }, + "invitesDisabled": { "type": "boolean" }, + "inviteNote": { "type": "string" } + } + }, + "repoViewDetail": { + "type": "object", + "required": [ + "did", + "handle", + "relatedRecords", + "indexedAt", + "moderation" + ], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "email": { "type": "string" }, + "relatedRecords": { "type": "array", "items": { "type": "unknown" } }, + "indexedAt": { "type": "string", "format": "datetime" }, + "moderation": { "type": "ref", "ref": "#moderationDetail" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "invitedBy": { + "type": "ref", + "ref": "com.atproto.server.defs#inviteCode" + }, + "invites": { + "type": "array", + "items": { + "type": "ref", + "ref": "com.atproto.server.defs#inviteCode" + } + }, + "invitesDisabled": { "type": "boolean" }, + "inviteNote": { "type": "string" }, + "emailConfirmedAt": { "type": "string", "format": "datetime" } + } + }, + "repoViewNotFound": { + "type": "object", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + }, + "recordView": { + "type": "object", + "required": [ + "uri", + "cid", + "value", + "blobCids", + "indexedAt", + "moderation", + "repo" + ], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "value": { "type": "unknown" }, + "blobCids": { + "type": "array", + "items": { "type": "string", "format": "cid" } + }, + "indexedAt": { "type": "string", "format": "datetime" }, + "moderation": { "type": "ref", "ref": "#moderation" }, + "repo": { "type": "ref", "ref": "#repoView" } + } + }, + "recordViewDetail": { + "type": "object", + "required": [ + "uri", + "cid", + "value", + "blobs", + "indexedAt", + "moderation", + "repo" + ], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "value": { "type": "unknown" }, + "blobs": { + "type": "array", + "items": { "type": "ref", "ref": "#blobView" } + }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "indexedAt": { "type": "string", "format": "datetime" }, + "moderation": { "type": "ref", "ref": "#moderationDetail" }, + "repo": { "type": "ref", "ref": "#repoView" } + } + }, + "recordViewNotFound": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "at-uri" } + } + }, + "moderation": { + "type": "object", + "properties": { + "subjectStatus": { "type": "ref", "ref": "#subjectStatusView" } + } + }, + "moderationDetail": { + "type": "object", + "properties": { + "subjectStatus": { + "type": "ref", + "ref": "#subjectStatusView" + } + } + }, + "blobView": { + "type": "object", + "required": ["cid", "mimeType", "size", "createdAt"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "mimeType": { "type": "string" }, + "size": { "type": "integer" }, + "createdAt": { "type": "string", "format": "datetime" }, + "details": { + "type": "union", + "refs": ["#imageDetails", "#videoDetails"] + }, + "moderation": { "type": "ref", "ref": "#moderation" } + } + }, + "imageDetails": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer" }, + "height": { "type": "integer" } + } + }, + "videoDetails": { + "type": "object", + "required": ["width", "height", "length"], + "properties": { + "width": { "type": "integer" }, + "height": { "type": "integer" }, + "length": { "type": "integer" } + } + } + } +} diff --git a/lexicons/com/atproto/admin/emitModerationEvent.json b/lexicons/tools/ozone/moderation/emitEvent.json similarity index 56% rename from lexicons/com/atproto/admin/emitModerationEvent.json rename to lexicons/tools/ozone/moderation/emitEvent.json index f32ad18461c..32c12065008 100644 --- a/lexicons/com/atproto/admin/emitModerationEvent.json +++ b/lexicons/tools/ozone/moderation/emitEvent.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.emitModerationEvent", + "id": "tools.ozone.moderation.emitEvent", "defs": { "main": { "type": "procedure", @@ -14,16 +14,17 @@ "event": { "type": "union", "refs": [ - "com.atproto.admin.defs#modEventTakedown", - "com.atproto.admin.defs#modEventAcknowledge", - "com.atproto.admin.defs#modEventEscalate", - "com.atproto.admin.defs#modEventComment", - "com.atproto.admin.defs#modEventLabel", - "com.atproto.admin.defs#modEventReport", - "com.atproto.admin.defs#modEventMute", - "com.atproto.admin.defs#modEventReverseTakedown", - "com.atproto.admin.defs#modEventUnmute", - "com.atproto.admin.defs#modEventEmail" + "tools.ozone.moderation.defs#modEventTakedown", + "tools.ozone.moderation.defs#modEventAcknowledge", + "tools.ozone.moderation.defs#modEventEscalate", + "tools.ozone.moderation.defs#modEventComment", + "tools.ozone.moderation.defs#modEventLabel", + "tools.ozone.moderation.defs#modEventReport", + "tools.ozone.moderation.defs#modEventMute", + "tools.ozone.moderation.defs#modEventReverseTakedown", + "tools.ozone.moderation.defs#modEventUnmute", + "tools.ozone.moderation.defs#modEventEmail", + "tools.ozone.moderation.defs#modEventTag" ] }, "subject": { @@ -45,7 +46,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#modEventView" + "ref": "tools.ozone.moderation.defs#modEventView" } }, "errors": [{ "name": "SubjectHasAction" }] diff --git a/lexicons/com/atproto/admin/getModerationEvent.json b/lexicons/tools/ozone/moderation/getEvent.json similarity index 79% rename from lexicons/com/atproto/admin/getModerationEvent.json rename to lexicons/tools/ozone/moderation/getEvent.json index 71499b94d9a..66d7642eed2 100644 --- a/lexicons/com/atproto/admin/getModerationEvent.json +++ b/lexicons/tools/ozone/moderation/getEvent.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.getModerationEvent", + "id": "tools.ozone.moderation.getEvent", "defs": { "main": { "type": "query", @@ -16,7 +16,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#modEventViewDetail" + "ref": "tools.ozone.moderation.defs#modEventViewDetail" } } } diff --git a/lexicons/com/atproto/admin/getRecord.json b/lexicons/tools/ozone/moderation/getRecord.json similarity index 83% rename from lexicons/com/atproto/admin/getRecord.json rename to lexicons/tools/ozone/moderation/getRecord.json index 7c513bec72c..ca5c480691a 100644 --- a/lexicons/com/atproto/admin/getRecord.json +++ b/lexicons/tools/ozone/moderation/getRecord.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.getRecord", + "id": "tools.ozone.moderation.getRecord", "defs": { "main": { "type": "query", @@ -17,7 +17,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#recordViewDetail" + "ref": "tools.ozone.moderation.defs#recordViewDetail" } }, "errors": [{ "name": "RecordNotFound" }] diff --git a/lexicons/com/atproto/admin/getRepo.json b/lexicons/tools/ozone/moderation/getRepo.json similarity index 82% rename from lexicons/com/atproto/admin/getRepo.json rename to lexicons/tools/ozone/moderation/getRepo.json index c7966158f11..b5af1f07f5f 100644 --- a/lexicons/com/atproto/admin/getRepo.json +++ b/lexicons/tools/ozone/moderation/getRepo.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.getRepo", + "id": "tools.ozone.moderation.getRepo", "defs": { "main": { "type": "query", @@ -16,7 +16,7 @@ "encoding": "application/json", "schema": { "type": "ref", - "ref": "com.atproto.admin.defs#repoViewDetail" + "ref": "tools.ozone.moderation.defs#repoViewDetail" } }, "errors": [{ "name": "RepoNotFound" }] diff --git a/lexicons/tools/ozone/moderation/queryEvents.json b/lexicons/tools/ozone/moderation/queryEvents.json new file mode 100644 index 00000000000..619d400800c --- /dev/null +++ b/lexicons/tools/ozone/moderation/queryEvents.json @@ -0,0 +1,104 @@ +{ + "lexicon": 1, + "id": "tools.ozone.moderation.queryEvents", + "defs": { + "main": { + "type": "query", + "description": "List moderation events related to a subject.", + "parameters": { + "type": "params", + "properties": { + "types": { + "type": "array", + "items": { "type": "string" }, + "description": "The types of events (fully qualified string in the format of tools.ozone.moderation.defs#modEvent) to filter by. If not specified, all events are returned." + }, + "createdBy": { + "type": "string", + "format": "did" + }, + "sortDirection": { + "type": "string", + "default": "desc", + "enum": ["asc", "desc"], + "description": "Sort direction for the events. Defaults to descending order of created at timestamp." + }, + "createdAfter": { + "type": "string", + "format": "datetime", + "description": "Retrieve events created after a given timestamp" + }, + "createdBefore": { + "type": "string", + "format": "datetime", + "description": "Retrieve events created before a given timestamp" + }, + "subject": { "type": "string", "format": "uri" }, + "includeAllUserRecords": { + "type": "boolean", + "default": false, + "description": "If true, events on all record types (posts, lists, profile etc.) owned by the did are returned" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "hasComment": { + "type": "boolean", + "description": "If true, only events with comments are returned" + }, + "comment": { + "type": "string", + "description": "If specified, only events with comments containing the keyword are returned" + }, + "addedLabels": { + "type": "array", + "items": { "type": "string" }, + "description": "If specified, only events where all of these labels were added are returned" + }, + "removedLabels": { + "type": "array", + "items": { "type": "string" }, + "description": "If specified, only events where all of these labels were removed are returned" + }, + "addedTags": { + "type": "array", + "items": { "type": "string" }, + "description": "If specified, only events where all of these tags were added are returned" + }, + "removedTags": { + "type": "array", + "items": { "type": "string" }, + "description": "If specified, only events where all of these tags were removed are returned" + }, + "reportTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["events"], + "properties": { + "cursor": { "type": "string" }, + "events": { + "type": "array", + "items": { + "type": "ref", + "ref": "tools.ozone.moderation.defs#modEventView" + } + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/queryModerationStatuses.json b/lexicons/tools/ozone/moderation/queryStatuses.json similarity index 90% rename from lexicons/com/atproto/admin/queryModerationStatuses.json rename to lexicons/tools/ozone/moderation/queryStatuses.json index e3e2a859bd2..624ffbbb4ad 100644 --- a/lexicons/com/atproto/admin/queryModerationStatuses.json +++ b/lexicons/tools/ozone/moderation/queryStatuses.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.queryModerationStatuses", + "id": "tools.ozone.moderation.queryStatuses", "defs": { "main": { "type": "query", @@ -74,6 +74,14 @@ "maximum": 100, "default": 50 }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "excludeTags": { + "type": "array", + "items": { "type": "string" } + }, "cursor": { "type": "string" } } }, @@ -88,7 +96,7 @@ "type": "array", "items": { "type": "ref", - "ref": "com.atproto.admin.defs#subjectStatusView" + "ref": "tools.ozone.moderation.defs#subjectStatusView" } } } diff --git a/lexicons/com/atproto/admin/searchRepos.json b/lexicons/tools/ozone/moderation/searchRepos.json similarity index 89% rename from lexicons/com/atproto/admin/searchRepos.json rename to lexicons/tools/ozone/moderation/searchRepos.json index acc5a70f942..3968b340828 100644 --- a/lexicons/com/atproto/admin/searchRepos.json +++ b/lexicons/tools/ozone/moderation/searchRepos.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "com.atproto.admin.searchRepos", + "id": "tools.ozone.moderation.searchRepos", "defs": { "main": { "type": "query", @@ -33,7 +33,7 @@ "type": "array", "items": { "type": "ref", - "ref": "com.atproto.admin.defs#repoView" + "ref": "tools.ozone.moderation.defs#repoView" } } } diff --git a/package.json b/package.json index 7fe531a530f..0c03df2affd 100644 --- a/package.json +++ b/package.json @@ -10,44 +10,46 @@ }, "scripts": { "lint:fix": "pnpm lint --fix", - "lint": "eslint . --ext .ts,.tsx", - "verify": "prettier --check . && pnpm lint", - "format": "prettier --write .", - "build": "pnpm -r --stream build", - "update-main-to-dist": "pnpm -r --stream update-main-to-dist", - "test": "LOG_ENABLED=false NODE_ENV=development ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test", - "test:withFlags": "LOG_ENABLED=false NODE_ENV=development ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test --", + "lint": "eslint . --ext .ts,.js", + "style:fix": "prettier --write .", + "style": "prettier --check .", + "verify": "pnpm --stream '/^verify:.+$/'", + "verify:style": "pnpm run style", + "verify:lint": "pnpm lint", + "verify:types": "tsc --build tsconfig.json", + "format": "pnpm lint:fix && pnpm style:fix", + "build": "pnpm --recursive --stream build", + "dev": "pnpm --stream '/^dev:.+$/'", + "dev:tsc": "tsc --build tsconfig.json --watch", + "dev:pkg": "pnpm --recursive --parallel --stream dev", + "test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test", + "test:withFlags": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test --", "changeset": "changeset", "release": "pnpm build && changeset publish", "version-packages": "changeset version && git add ." }, "devDependencies": { + "@atproto/dev-env": "workspace:^", "@babel/core": "^7.18.6", "@babel/preset-env": "^7.18.6", "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.2", - "@npmcli/package-json": "^3.0.0", "@swc/core": "^1.3.42", "@swc/jest": "^0.2.24", "@types/jest": "^28.1.4", - "@types/node": "^18.0.0", + "@types/node": "^18.19.24", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "babel-eslint": "^10.1.0", "dotenv": "^16.0.3", - "esbuild": "^0.14.48", - "esbuild-node-externals": "^1.5.0", - "esbuild-plugin-handlebars": "^1.0.2", "eslint": "^8.24.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", - "handlebars-jest": "^1.0.0", "jest": "^28.1.2", "node-gyp": "^9.3.1", "pino-pretty": "^9.1.0", "prettier": "^2.7.1", "prettier-config-standard": "^5.0.0", - "ts-node": "^10.8.2", "typescript": "^5.3.3" }, "workspaces": { diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 997f86931fa..445b11ea7dc 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,145 @@ # @atproto/api +## 0.12.2 + +### Patch Changes + +- [#2344](https://github.com/bluesky-social/atproto/pull/2344) [`abc6f82da`](https://github.com/bluesky-social/atproto/commit/abc6f82da38abef2b1bbe8d9e41a0534a5418c9e) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Support muting words that contain apostrophes and other punctuation + +## 0.12.1 + +### Patch Changes + +- [#2342](https://github.com/bluesky-social/atproto/pull/2342) [`eb7668c07`](https://github.com/bluesky-social/atproto/commit/eb7668c07d44f4b42ea2cc28143c64f4ba3312f5) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Adds the `associated` property to `profile` and `profile-basic` views, bringing them in line with `profile-detailed` views. + +## 0.12.0 + +### Minor Changes + +- [#2169](https://github.com/bluesky-social/atproto/pull/2169) [`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Build system rework, stop bundling dependencies. + +### Patch Changes + +- [#2338](https://github.com/bluesky-social/atproto/pull/2338) [`36f2e966c`](https://github.com/bluesky-social/atproto/commit/36f2e966cba6cc90ba4320520da5c7381cfb8086) Thanks [@pfrazee](https://github.com/pfrazee)! - Fix: correctly detected blocked quote-posts when moderating posts + +- Updated dependencies [[`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9)]: + - @atproto/common-web@0.3.0 + - @atproto/lexicon@0.4.0 + - @atproto/syntax@0.3.0 + - @atproto/xrpc@0.5.0 + +## 0.11.2 + +### Patch Changes + +- [#2328](https://github.com/bluesky-social/atproto/pull/2328) [`7dd9941b7`](https://github.com/bluesky-social/atproto/commit/7dd9941b73dbbd82601740e021cc87d765af60ca) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Remove unecessary escapes from regex, which was causing a minification error when bundled in React Native. + +## 0.11.1 + +### Patch Changes + +- [#2312](https://github.com/bluesky-social/atproto/pull/2312) [`219480764`](https://github.com/bluesky-social/atproto/commit/2194807644cbdb0021e867437693300c1b0e55f5) Thanks [@pfrazee](https://github.com/pfrazee)! - Fixed an issue that would cause agent clones to drop the PDS URI config. + +## 0.11.0 + +### Minor Changes + +- [#2302](https://github.com/bluesky-social/atproto/pull/2302) [`4eaadc0ac`](https://github.com/bluesky-social/atproto/commit/4eaadc0acb6b73b9745dd7a2b929d02e58083ab0) Thanks [@dholms](https://github.com/dholms)! - - Breaking changes + - Redesigned the `moderate*` APIs which now output a `ModerationUI` object. + - `agent.getPreferences()` output object `BskyPreferences` has been modified. + - Moved Ozone routes from `com.atproto.admin` to `tools.ozone` namespace. + - Additions + - Added support for labeler configuration in `Agent.configure()` and `agent.configureLabelerHeader()`. + - Added `agent.addLabeler()` and `agent.removeLabeler()` preference methods. + - Muted words and hidden posts are now handled in the `moderate*` APIs. + - Added `agent.getLabelers()` and `agent.getLabelDefinitions()`. + - Added `agent.configureProxyHeader()` and `withProxy()` methods to support remote service proxying behaviors. + +### Patch Changes + +- Updated dependencies [[`4eaadc0ac`](https://github.com/bluesky-social/atproto/commit/4eaadc0acb6b73b9745dd7a2b929d02e58083ab0)]: + - @atproto/common-web@0.2.4 + - @atproto/lexicon@0.3.3 + - @atproto/syntax@0.2.1 + - @atproto/xrpc@0.4.3 + +## 0.10.5 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + +## 0.10.4 + +### Patch Changes + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Export regex from rich text detection + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Disallow rare unicode whitespace characters from tags + +- [#2260](https://github.com/bluesky-social/atproto/pull/2260) [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Allow tags to lead with numbers + +## 0.10.3 + +### Patch Changes + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Fix double sanitization bug when editing muted words. + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - More sanitization of muted words, including newlines and leading/trailing whitespace + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add `sanitizeMutedWordValue` util + +- [#2247](https://github.com/bluesky-social/atproto/pull/2247) [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Handle hash emoji in mute words + +## 0.10.2 + +### Patch Changes + +- [#2245](https://github.com/bluesky-social/atproto/pull/2245) [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410) Thanks [@mary-ext](https://github.com/mary-ext)! - Prevent hashtag emoji from being parsed as a tag + +- [#2218](https://github.com/bluesky-social/atproto/pull/2218) [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Fix mute word upsert logic by ensuring we're comparing sanitized word values + +- [#2245](https://github.com/bluesky-social/atproto/pull/2245) [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410) Thanks [@mary-ext](https://github.com/mary-ext)! - Properly calculate length of tag + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4)]: + - @atproto/syntax@0.2.0 + - @atproto/lexicon@0.3.2 + - @atproto/xrpc@0.4.2 + +## 0.10.1 + +### Patch Changes + +- [#2215](https://github.com/bluesky-social/atproto/pull/2215) [`514aab92d`](https://github.com/bluesky-social/atproto/commit/514aab92d26acd43859285f46318e386846522b1) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add missing `getPreferences` union return types + +## 0.10.0 + +### Minor Changes + +- [#2170](https://github.com/bluesky-social/atproto/pull/2170) [`4c511b3d9`](https://github.com/bluesky-social/atproto/commit/4c511b3d9de41ffeae3fc11db941e7df04f4468a) Thanks [@dholms](https://github.com/dholms)! - Add lexicons and methods for account migration + +### Patch Changes + +- [#2195](https://github.com/bluesky-social/atproto/pull/2195) [`b60719480`](https://github.com/bluesky-social/atproto/commit/b60719480f5f00bffd074a40e8ddc03aa93d137d) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add muted words/tags and hidden posts prefs and methods" + +## 0.9.8 + +### Patch Changes + +- [#2192](https://github.com/bluesky-social/atproto/pull/2192) [`f79cc6339`](https://github.com/bluesky-social/atproto/commit/f79cc63390ae9dbd47a4ff5d694eec25b78b788e) Thanks [@foysalit](https://github.com/foysalit)! - Tag event on moderation subjects and allow filtering events and subjects by tags + +## 0.9.7 + +### Patch Changes + +- [#2188](https://github.com/bluesky-social/atproto/pull/2188) [`8c94979f7`](https://github.com/bluesky-social/atproto/commit/8c94979f73fc5057449e24e66ef2e09b0e17e55b) Thanks [@dholms](https://github.com/dholms)! - Added timelineIndex to savedFeedsPref + +## 0.9.6 + +### Patch Changes + +- [#2124](https://github.com/bluesky-social/atproto/pull/2124) [`e4ec7af03`](https://github.com/bluesky-social/atproto/commit/e4ec7af03608949fc3b00a845f547a77599b5ad0) Thanks [@foysalit](https://github.com/foysalit)! - Allow filtering for comment, label, report type and date range on queryModerationEvents endpoint. + ## 0.9.5 ### Patch Changes diff --git a/packages/api/README.md b/packages/api/README.md index c5e66d70862..201bba29f67 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -178,87 +178,70 @@ console.log(rt3.graphemeLength) // => 1 Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including: +- Moderator labeling - User muting (including mutelists) - User blocking -- Moderator labeling +- Mutewords +- Hidden posts -For more information, see the [Moderation Documentation](./docs/moderation.md) or the associated [Labels Reference](./docs/labels.md). +For more information, see the [Moderation Documentation](./docs/moderation.md). ```typescript -import { moderatePost, moderateProfile } from '@atproto/api' +import { moderatePost } from '@atproto/api' + +// First get the user's moderation prefs and their label definitions +// = + +const prefs = await agent.getPreferences() +const labelDefs = await agent.getLabelDefinitions(prefs) // We call the appropriate moderation function for the content // = -const postMod = moderatePost(postView, getOpts()) -const profileMod = moderateProfile(profileView, getOpts()) +const postMod = moderatePost(postView, { + userDid: agent.session.did, + moderationPrefs: prefs.moderationPrefs, + labelDefs, +}) // We then use the output to decide how to affect rendering // = -if (postMod.content.filter) { - // don't render in feeds or similar - // in contexts where this is disruptive (eg threads) you should ignore this and instead check blur +// in feeds +if (postMod.ui('contentList').filter) { + // don't include in feeds } -if (postMod.content.blur) { - // render the whole object behind a cover (use postMod.content.cause to explain) - if (postMod.content.noOverride) { +if (postMod.ui('contentList').blur) { + // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain) + if (postMod.ui('contentList').noOverride) { // do not allow the cover the be removed } } -if (postMod.content.alert) { - // render a warning on the content (use postMod.content.cause to explain) +if (postMod.ui('contentList').alert || postMod.ui('contentList').inform) { + // render warnings on the post + // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs } -if (postMod.embed.blur) { - // render the embedded media behind a cover (use postMod.embed.cause to explain) - if (postMod.embed.noOverride) { + +// viewed directly +if (postMod.ui('contentView').filter) { + // don't include in feeds +} +if (postMod.ui('contentView').blur) { + // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain) + if (postMod.ui('contentView').noOverride) { // do not allow the cover the be removed } } -if (postMod.embed.alert) { - // render a warning on the embedded media (use postMod.embed.cause to explain) -} -if (postMod.avatar.blur) { - // render the avatar behind a cover -} -if (postMod.avatar.alert) { - // render an alert on the avatar +if (postMod.ui('contentView').alert || postMod.ui('contentView').inform) { + // render warnings on the post + // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs } -// The options passed into `apply()` supply the user's preferences -// = - -function getOpts() { - return { - // the logged-in user's DID - userDid: 'did:plc:1234...', - - // is adult content allowed? - adultContentEnabled: true, - - // the global label settings (used on self-labels) - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... - }, - - // the per-labeler settings - labelers: [ - { - labeler: { - did: '...', - displayName: 'My mod service', - }, - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... - }, - }, - ], +// post embeds in all contexts +if (postMod.ui('contentMedia').blur) { + // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain) + if (postMod.ui('contentMedia').noOverride) { + // do not allow the cover the be removed } } ``` diff --git a/packages/api/babel.config.js b/packages/api/babel.config.js deleted file mode 100644 index ee58f35df11..00000000000 --- a/packages/api/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [['@babel/preset-env']], -} diff --git a/packages/api/build.js b/packages/api/build.js deleted file mode 100644 index 30fbe7cea56..00000000000 --- a/packages/api/build.js +++ /dev/null @@ -1,15 +0,0 @@ -const { nodeExternalsPlugin } = require('esbuild-node-externals') - -const buildShallow = - process.argv.includes('--shallow') || process.env.ATP_BUILD_SHALLOW === 'true' - -require('esbuild').build({ - logLevel: 'info', - entryPoints: ['src/index.ts'], - bundle: true, - sourcemap: true, - outdir: 'dist', - platform: 'browser', - format: 'cjs', - plugins: buildShallow ? [nodeExternalsPlugin()] : [], -}) diff --git a/packages/api/definitions/labels.json b/packages/api/definitions/labels.json index acb6fa02c49..913ad4365c6 100644 --- a/packages/api/definitions/labels.json +++ b/packages/api/definitions/labels.json @@ -1,224 +1,169 @@ [ { - "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": "!warn", "configurable": false, - "labels": [ - { - "id": "dmca-violation", - "preferences": ["hide"], - "flags": ["no-override"], - "onwarn": "blur" - }, - { - "id": "doxxing", - "preferences": ["hide"], - "flags": ["no-override"], - "onwarn": "blur" + "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" - } - ] - }, - { - "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" + "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": "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": "ignore", + "flags": [], + "severity": "none", + "blurs": "media", + "behaviors": { + "account": { + "avatar": "blur", + "banner": "blur" + }, + "profile": { + "avatar": "blur", + "banner": "blur" + }, + "content": { + "contentMedia": "blur" } - ] + } }, { - "id": "misinfo", + "identifier": "graphic-media", + "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 deleted file mode 100644 index 943d3f54613..00000000000 --- a/packages/api/docs/labels.md +++ /dev/null @@ -1,554 +0,0 @@ - - -# Labels - -This document is a reference for the labels used in the SDK. - -**⚠️ Note**: These labels are still in development and may change over time. Not all are currently in use. - -## Key - -### Label Preferences - -The possible client interpretations for a label. - -- ignore Do nothing with the label. -- warn Provide some form of warning on the content (see "On Warn" behavior). -- hide Remove the content from feeds and apply the warning when directly viewed. - -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. - -### Flags - -Additional behaviors which a label can adopt. - -- no-override The user cannot click through any covering of content created by the label. -- adult The user must have adult content enabled to configure the label. If adult content is not enabled, the label must adopt the strictest preference. - -### On Warn - -The kind of UI behavior used when a warning must be applied. - -- blur Hide all of the content behind an interstitial. -- blur-media Hide only the media within the content (ie images) behind an interstitial. -- alert Display a descriptive warning but do not hide the content. -- null Do nothing. - -## Label Behaviors - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDGroupPreferencesConfigurableFlagsOn Warn
!hidesystemhideno-overrideblur
!no-promotesystemhidenull
!warnsystemwarnblur
!no-unauthenticatedsystemhideno-override, unauthedblur
dmca-violationlegalhideno-overrideblur
doxxinglegalhideno-overrideblur
pornsexualignore, warn, hideadultblur-media
sexualsexualignore, warn, hideadultblur-media
nuditysexualignore, warn, hideadultblur-media
nsflviolenceignore, warn, hideadultblur-media
corpseviolenceignore, warn, hideadultblur-media
goreviolenceignore, warn, hideadultblur-media
tortureviolenceignore, warn, hideadultblur
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.

-
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..571660d00fa 100644 --- a/packages/api/docs/moderation.md +++ b/packages/api/docs/moderation.md @@ -2,17 +2,11 @@ Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including: +- Moderator labeling - User muting (including mutelists) - User blocking -- Moderator labeling - -For more information, see the [Moderation Documentation](./docs/moderation.md) or the associated [Labels Reference](./docs/labels.md). - -Additional docs: - -- [Labels Reference](./labels.md) -- [Post Moderation Behaviors](./moderation-behaviors/posts.md) -- [Profile Moderation Behaviors](./moderation-behaviors/profiles.md) +- Mutewords +- Hidden posts ## Configuration @@ -23,131 +17,244 @@ Every moderation function takes a set of options which look like this: // the logged-in user's DID userDid: 'did:plc:1234...', - // is adult content allowed? - adultContentEnabled: true, + moderationPrefs: { + // is adult content allowed? + adultContentEnabled: true, - // the global label settings (used on self-labels) - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... - }, + // the global label settings (used on self-labels) + labels: { + porn: 'hide', + sexual: 'warn', + nudity: 'ignore', + // ... + }, - // the per-labeler settings - labelers: [ - { - labeler: { - did: '...', - displayName: 'My mod service' - }, - labels: { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - // ... + // the subscribed labelers and their label settings + labelers: [ + { + did: 'did:plc:1234...', + labels: { + porn: 'hide', + sexual: 'warn', + nudity: 'ignore', + // ... + } } - } - ] + ], + + mutedWords: [/* ... */], + hiddenPosts: [/* ... */] + }, + + // custom label definitions + labelDefs: { + // labelerDid => defs[] + 'did:plc:1234...': [ + /* ... */ + ] + } } ``` This should match the following interfaces: ```typescript -interface ModerationOpts { - userDid: string +export interface ModerationPrefsLabeler { + did: string + labels: Record +} + +export interface ModerationPrefs { adultContentEnabled: boolean labels: Record - labelers: LabelerSettings[] + labelers: ModerationPrefsLabeler[] + mutedWords: AppBskyActorDefs.MutedWord[] + hiddenPosts: string[] } -interface Labeler { - did: string - displayName: string +export interface ModerationOpts { + userDid: string | undefined + prefs: ModerationPrefs + /** + * Map of labeler did -> custom definitions + */ + labelDefs?: Record } +``` -type LabelPreference = 'ignore' | 'warn' | 'hide' +You can quickly grab the `ModerationPrefs` using the `agent.getPreferences()` method: -interface LabelerSettings { - labeler: Labeler - labels: Record -} +```typescript +const prefs = await agent.getPreferences() +moderatePost(post, { + userDid: /*...*/, + prefs: prefs.moderationPrefs, + labelDefs: /*...*/ +}) ``` -## Posts +To gather the label definitions (`labelDefs`) see the _Labelers_ section below. -Applications need to produce the [Post Moderation Behaviors](./moderation-behaviors/posts.md) using the `moderatePost()` API. +## Labelers + +Labelers are services that provide moderation labels. Your application will typically have 1+ top-level labelers set with the ability to do "takedowns" on content. This is controlled via this static function, though the default is to use Bluesky's moderation: ```typescript -import { moderatePost } from '@atproto/api' +BskyAgent.configure({ + appLabelers: ['did:web:my-labeler.com'], +}) +``` -const postMod = moderatePost(postView, getOpts()) +Users may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed. -if (postMod.content.filter) { - // don't render in feeds or similar - // in contexts where this is disruptive (eg threads) you should ignore this and instead check blur -} -if (postMod.content.blur) { - // render the whole object behind a cover (use postMod.content.cause to explain) - if (postMod.content.noOverride) { - // do not allow the cover the be removed - } -} -if (postMod.content.alert) { - // render a warning on the content (use postMod.content.cause to explain) -} -if (postMod.embed.blur) { - // render the embedded media behind a cover (use postMod.embed.cause to explain) - if (postMod.embed.noOverride) { - // do not allow the cover the be removed - } -} -if (postMod.embed.alert) { - // render a warning on the embedded media (use postMod.embed.cause to explain) -} -if (postMod.avatar.blur) { - // render the avatar behind a cover -} -if (postMod.avatar.alert) { - // render an alert on the avatar +Labelers publish a `app.bsky.labeler.service` record that looks like this: + +```js +{ + $type: 'app.bsky.labeler.service', + policies: { + // the list of label values the labeler will publish + labelValues: [ + 'rude', + ], + // any custom definitions the labeler will be using + labelValueDefinitions: [ + { + identifier: 'rude', + blurs: 'content', + severity: 'alert', + defaultSetting: 'warn', + adultOnly: false, + locales: [ + { + lang: 'en', + name: 'Rude', + description: 'Not keeping things civil.', + }, + ], + }, + ], + }, + createdAt: '2024-03-12T17:17:17.215Z' } ``` -## Profiles +The label value definition are custom labels which only apply to that labeler. Your client needs to sync those definitions in order to correctly interpret them. To do that, call `app.bsky.labeler.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours). + +Here is how to do this: + +```typescript +import { BskyAgent } from '@atproto/api' + +const agent = new BskyAgent() +// assume `agent` is a signed in session +const prefs = await agent.getPreferences() +const labelDefs = await agent.getLabelDefinitions(prefs) + +moderatePost(post, { + userDid: agent.session.did, + prefs: prefs.moderationPrefs, + labelDefs, +}) +``` + +## The `moderate*()` APIs + +The SDK exports methods to moderate the different kinds of content on the network. + +```typescript +import { + moderateProfile, + moderatePost, + moderateNotification, + moderateFeedGen, + moderateUserList, + moderateLabeler, +} from '@atproto/api' +``` + +Each of these follows the same API signature: + +```typescript +const res = moderatePost(post, moderationOptions) +``` -Applications need to produce the [Profile Moderation Behaviors](./moderation-behaviors/profiles.md) using the `moderateProfile()` API. +The response object provides an API for figuring out what your UI should do in different contexts. ```typescript -import { moderateProfile } from '@atproto/api' +res.ui(context) /* => -const profileMod = moderateProfile(profileView, getOpts()) +ModerationUI { + filter: boolean // should the content be removed from the interface? + blur: boolean // should the content be put behind a cover? + alert: boolean // should an alert be put on the content? (negative) + inform: boolean // should an informational notice be put on the content? (neutral) + noOverride: boolean // if blur=true, should the UI disable opening the cover? -if (profileMod.account.filter) { - // don't render in discovery + // the reasons for each of the flags: + filters: ModerationCause[] + blurs: ModerationCause[] + alerts: ModerationCause[] + informs: ModerationCause[] } -if (profileMod.account.blur) { - // render the whole account behind a cover (use profileMod.account.cause to explain) - if (profileMod.account.noOverride) { - // do not allow the cover the be removed - } +*/ +``` + +There are multiple UI contexts available: + +- `profileList` A profile being listed, eg in search or a follower list +- `profileView` A profile being viewed directly +- `avatar` The user's avatar in any context +- `banner` The user's banner in any context +- `displayName` The user's display name in any context +- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc +- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc +- `contentMedia ` Media inside the content, eg a picture embedded in a post + +Here's how a post in a feed would use these tools to make a decision: + +```typescript +const mod = moderatePost(post, moderationOptions) + +if (mod.ui('contentList').filter) { + // dont show the post } -if (profileMod.account.alert) { - // render a warning on the account (use profileMod.account.cause to explain) +if (mod.ui('contentList').blur) { + // cover the post with the explanation from mod.ui('contentList').blurs[0] + if (mod.ui('contentList').noOverride) { + // dont allow the cover to be removed + } } -if (profileMod.profile.blur) { - // render the profile information (display name, bio) behind a cover - if (profileMod.profile.noOverride) { - // do not allow the cover the be removed +if (mod.ui('contentMedia').blur) { + // cover the post's embbedded images with the explanation from mod.ui('contentMedia').blurs[0] + if (mod.ui('contentMedia').noOverride) { + // dont allow the cover to be removed } } -if (profileMod.profile.alert) { - // render a warning on the profile (use profileMod.profile.cause to explain) +if (mod.ui('avatar').blur) { + // cover the avatar with the explanation from mod.ui('avatar').blurs[0] + if (mod.ui('avatar').noOverride) { + // dont allow the cover to be removed + } } -if (profileMod.avatar.blur) { - // render the avatar behind a cover +for (const alert of mod.ui('contentList').alerts) { + // render this alert } -if (profileMod.avatar.alert) { - // render an alert on the avatar +for (const inform of mod.ui('contentList').informs) { + // render this inform } ``` + +## Sending moderation reports + +Any Labeler is capable of receiving moderation reports. As a result, you need to specify which labeler should receive the report. You do this with the `Atproto-Proxy` header: + +```typescript +agent + .withProxy('atproto_labeler', 'did:web:my-labeler.com') + .createModerationReport({ + reasonType: 'com.atproto.moderation.defs#reasonViolation', + reason: 'They were being such a jerk to me!', + subject: { did: 'did:web:bob.com' }, + }) +``` diff --git a/packages/api/jest.bench.config.js b/packages/api/jest.bench.config.js deleted file mode 100644 index b3bb54fca4d..00000000000 --- a/packages/api/jest.bench.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const base = require('./jest.config') - -module.exports = { - ...base, - roots: ['/bench'], - testRegex: '(.*.bench.ts)', - testTimeout: 3000000, -} diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js index d8abff65a6f..23994feaf41 100644 --- a/packages/api/jest.config.js +++ b/packages/api/jest.config.js @@ -1,6 +1,9 @@ -const base = require('../../jest.config.base.js') - +/** @type {import('jest').Config} */ module.exports = { - ...base, displayName: 'API', + transform: { '^.+\\.(t|j)s$': '@swc/jest' }, + transformIgnorePatterns: [`/node_modules/(?!get-port)`], + testTimeout: 60000, + setupFiles: ['/../../jest.setup.ts'], + setupFilesAfterEnv: ['/jest.setup.ts'], } diff --git a/packages/api/jest.d.ts b/packages/api/jest.d.ts new file mode 100644 index 00000000000..68ace0e840d --- /dev/null +++ b/packages/api/jest.d.ts @@ -0,0 +1,20 @@ +declare namespace jest { + // eslint-disable-next-line + interface Matchers { + toBeModerationResult( + expected: ModerationTestSuiteResultFlag[] | undefined, + context?: string, + stringifiedResult?: string, + ignoreCause?: boolean, + ): R + } + + interface Expect { + toBeModerationResult( + expected: ModerationTestSuiteResultFlag[] | undefined, + context?: string, + stringifiedResult?: string, + ignoreCause?: boolean, + ): void + } +} diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts new file mode 100644 index 00000000000..f0e4aaaf36f --- /dev/null +++ b/packages/api/jest.setup.ts @@ -0,0 +1,97 @@ +import { ModerationUI } from './src' +import { ModerationTestSuiteResultFlag } from './tests/util/moderation-behavior' + +expect.extend({ + toBeModerationResult( + actual: ModerationUI, + expected: ModerationTestSuiteResultFlag[] | undefined, + context: string = '', + stringifiedResult: string | undefined = undefined, + _ignoreCause = false, + ) { + const fail = (msg: string) => ({ + pass: false, + message: () => + `${msg}.${ + stringifiedResult ? ` Full result: ${stringifiedResult}` : '' + }`, + }) + // 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 (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`) + } + if (actual.blur) { + return fail(`${context} expected to be a no-op, got blur=true`) + } + if (actual.filter) { + return fail(`${context} expected to be a no-op, got filter=true`) + } + if (actual.noOverride) { + 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}`) + // } + const expectedInform = expected.includes('inform') + if (!!actual.inform !== expectedInform) { + return fail( + `${context} expected to be inform=${expectedInform}, got ${ + actual.inform || false + }`, + ) + } + const expectedAlert = expected.includes('alert') + if (!!actual.alert !== expectedAlert) { + return fail( + `${context} expected to be alert=${expectedAlert}, got ${ + actual.alert || false + }`, + ) + } + const expectedBlur = expected.includes('blur') + if (!!actual.blur !== expectedBlur) { + return fail( + `${context} expected to be blur=${expectedBlur}, got ${ + actual.blur || false + }`, + ) + } + const expectedFilter = expected.includes('filter') + if (!!actual.filter !== expectedFilter) { + return fail( + `${context} expected to be filter=${expectedFilter}, got ${ + actual.filter || false + }`, + ) + } + const expectedNoOverride = expected.includes('noOverride') + if (!!actual.noOverride !== expectedNoOverride) { + return fail( + `${context} expected to be noOverride=${expectedNoOverride}, got ${ + actual.noOverride || false + }`, + ) + } + } + return { pass: true, message: () => '' } + }, +}) diff --git a/packages/api/package.json b/packages/api/package.json index e00c9438d2f..993bcefd523 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.9.5", + "version": "0.12.2", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ @@ -14,20 +14,12 @@ "url": "https://github.com/bluesky-social/atproto", "directory": "packages/api" }, - "main": "src/index.ts", - "publishConfig": { - "main": "dist/index.js", - "types": "dist/index.d.ts" - }, + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "codegen": "pnpm docgen && node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*", - "docgen": "node ./scripts/generate-docs.mjs", - "build": "node ./build.js", - "postbuild": "tsc --build tsconfig.build.json", - "update-main-to-dist": "node ../../update-main-to-dist.js packages/api", - "test": "jest", - "bench": "jest --config jest.bench.config.js", - "bench:profile": "node --inspect-brk ../../node_modules/.bin/jest --config jest.bench.config.js" + "codegen": "node ./scripts/generate-code.mjs && lex gen-api ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/tools/ozone/*/*", + "build": "tsc --build tsconfig.build.json", + "test": "jest" }, "dependencies": { "@atproto/common-web": "workspace:^", @@ -35,13 +27,11 @@ "@atproto/syntax": "workspace:^", "@atproto/xrpc": "workspace:^", "multiformats": "^9.9.0", - "tlds": "^1.234.0", - "typed-emitter": "^2.1.0", - "zod": "^3.21.4" + "tlds": "^1.234.0" }, "devDependencies": { "@atproto/lex-cli": "workspace:^", - "@atproto/dev-env": "workspace:^", - "common-tags": "^1.8.2" + "get-port": "^6.1.2", + "jest": "^28.1.2" } } 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..274d7c30178 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 {InterpretedLabelValueDefinition, 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 deleted file mode 100644 index 1e1d0d7a6a6..00000000000 --- a/packages/api/scripts/docs/labels.mjs +++ /dev/null @@ -1,164 +0,0 @@ -import * as url from 'url' -import { readFileSync, writeFileSync } from 'fs' -import { join } from 'path' -import { stripIndent } from 'common-tags' - -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) - -const labelsDef = JSON.parse( - readFileSync( - join(__dirname, '..', '..', 'definitions', 'labels.json'), - 'utf8', - ), -) -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') - -function doc() { - return stripIndent` - - -# Labels - -This document is a reference for the labels used in the SDK. - -**⚠️ Note**: These labels are still in development and may change over time. Not all are currently in use. - -## Key - -### Label Preferences - -The possible client interpretations for a label. - -- ignore Do nothing with the label. -- warn Provide some form of warning on the content (see "On Warn" behavior). -- hide Remove the content from feeds and apply the warning when directly viewed. - -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. - -### Flags - -Additional behaviors which a label can adopt. - -- no-override The user cannot click through any covering of content created by the label. -- adult The user must have adult content enabled to configure the label. If adult content is not enabled, the label must adopt the strictest preference. - -### On Warn - -The kind of UI behavior used when a warning must be applied. - -- blur Hide all of the content behind an interstitial. -- blur-media Hide only the media within the content (ie images) behind an interstitial. -- alert Display a descriptive warning but do not hide the content. -- null Do nothing. - -## Label Behaviors - - - - - - - - - - - ${labelsRef()} -
IDGroupPreferencesConfigurableFlagsOn 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` - - ${label.id} - ${group.id} - ${label.preferences.join(', ')} - ${group.configurable ? '✅' : '❌'} - ${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') -} - -export {} 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 deleted file mode 100644 index f7d4a1b2424..00000000000 --- a/packages/api/scripts/generate-docs.mjs +++ /dev/null @@ -1,5 +0,0 @@ -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..bec2834e9e3 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -17,8 +17,12 @@ import { AtpAgentGlobalOpts, AtpPersistSessionHandler, AtpAgentOpts, + AtprotoServiceType, } from './types' +import { BSKY_LABELER_DID } from './const' +const MAX_MOD_AUTHORITIES = 3 +const MAX_LABELERS = 10 const REFRESH_SESSION = 'com.atproto.server.refreshSession' /** @@ -29,15 +33,13 @@ export class AtpAgent { service: URL api: AtpServiceClient session?: AtpSessionData + labelersHeader: string[] = [] + proxyHeader: string | undefined + pdsUrl: URL | undefined // The PDS URL, driven by the did doc. May be undefined. - /** - * The PDS URL, driven by the did doc. May be undefined. - */ - pdsUrl: URL | undefined - - private _baseClient: AtpBaseClient - private _persistSession?: AtpPersistSessionHandler - private _refreshSessionPromise: Promise | undefined + protected _baseClient: AtpBaseClient + protected _persistSession?: AtpPersistSessionHandler + protected _refreshSessionPromise: Promise | undefined get com() { return this.api.com @@ -48,11 +50,21 @@ export class AtpAgent { */ static fetch: AtpAgentFetchHandler | undefined = defaultFetchHandler + /** + * The labelers to be used across all requests with the takedown capability + */ + static appLabelers: string[] = [BSKY_LABELER_DID] + /** * Configures the API globally. */ static configure(opts: AtpAgentGlobalOpts) { - AtpAgent.fetch = opts.fetch + if (opts.fetch) { + AtpAgent.fetch = opts.fetch + } + if (opts.appLabelers) { + AtpAgent.appLabelers = opts.appLabelers + } } constructor(opts: AtpAgentOpts) { @@ -66,6 +78,28 @@ export class AtpAgent { this.api = this._baseClient.service(opts.service) } + clone() { + const inst = new AtpAgent({ + service: this.service, + }) + this.copyInto(inst) + return inst + } + + copyInto(inst: AtpAgent) { + inst.session = this.session + inst.labelersHeader = this.labelersHeader + inst.proxyHeader = this.proxyHeader + inst.pdsUrl = this.pdsUrl + inst.api.xrpc.uri = this.pdsUrl || this.service + } + + withProxy(serviceType: AtprotoServiceType, did: string) { + const inst = this.clone() + inst.configureProxyHeader(serviceType, did) + return inst + } + /** * Is there any active session? */ @@ -81,6 +115,24 @@ 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 + } + + /** + * Configures the atproto-proxy header to be applied on requests + */ + configureProxyHeader(serviceType: AtprotoServiceType, did: string) { + if (did.startsWith('did:')) { + this.proxyHeader = `${did}#${serviceType}` + } + } + /** * Create a new account and hydrate its session in this agent. */ @@ -194,13 +246,27 @@ 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.proxyHeader) { + reqHeaders = { + ...reqHeaders, + 'atproto-proxy': this.proxyHeader, + } + } + reqHeaders = { + ...reqHeaders, + 'atproto-accept-labelers': AtpAgent.appLabelers + .map((str) => `${str};redact`) + .concat(this.labelersHeader.filter((str) => str.startsWith('did:'))) + .slice(0, MAX_LABELERS) + .join(', '), + } return reqHeaders } @@ -224,7 +290,7 @@ export class AtpAgent { let res = await AtpAgent.fetch( reqUri, reqMethod, - this._addAuthHeader(reqHeaders), + this._addHeaders(reqHeaders), reqBody, ) @@ -237,7 +303,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 606e06dcda8..e7e2a1717cc 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -1,22 +1,30 @@ -import { AtUri } from '@atproto/syntax' +import { AtUri, ensureValidDid } from '@atproto/syntax' import { AtpAgent } from './agent' import { AppBskyFeedPost, AppBskyActorProfile, AppBskyActorDefs, + AppBskyLabelerDefs, ComAtprotoRepoPutRecord, } from './client' import { BskyPreferences, - BskyLabelPreference, BskyFeedViewPreference, BskyThreadViewPreference, BskyInterestsPreference, } from './types' +import { + InterpretedLabelValueDefinition, + LabelPreference, + ModerationPrefs, +} from './moderation/types' +import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' +import { sanitizeMutedWordValue } from './util' +import { interpretLabelValueDefinitions } from './moderation' const FEED_VIEW_PREF_DEFAULTS = { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -36,6 +44,14 @@ declare global { } export class BskyAgent extends AtpAgent { + clone() { + const inst = new BskyAgent({ + service: this.service, + }) + this.copyInto(inst) + return inst + } + get app() { return this.api.app } @@ -97,6 +113,40 @@ 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 getLabelDefinitions( + prefs: BskyPreferences | ModerationPrefs | string[], + ): Promise> { + // collect the labeler dids + let dids: string[] = BskyAgent.appLabelers + if (isBskyPrefs(prefs)) { + dids = dids.concat(prefs.moderationPrefs.labelers.map((l) => l.did)) + } else if (isModPrefs(prefs)) { + dids = dids.concat(prefs.labelers.map((l) => l.did)) + } else { + dids = dids.concat(prefs) + } + + // fetch their definitions + const labelers = await this.getLabelers({ + dids, + detailed: true, + }) + + // assemble a map of labeler dids to the interpretted label value definitions + const labelDefs = {} + if (labelers.data) { + for (const labeler of labelers.data + .views as AppBskyLabelerDefs.LabelerViewDetailed[]) { + labelDefs[labeler.creator.did] = interpretLabelValueDefinitions(labeler) + } + } + + return labelDefs + } + async post( record: Partial & Omit, @@ -321,41 +371,59 @@ export class BskyAgent extends AtpAgent { }, }, threadViewPrefs: { ...THREAD_VIEW_PREF_DEFAULTS }, - adultContentEnabled: false, - contentLabels: {}, + moderationPrefs: { + adultContentEnabled: false, + labels: { ...DEFAULT_LABEL_SETTINGS }, + labelers: BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + mutedWords: [], + hiddenPosts: [], + }, birthDate: undefined, interests: { tags: [], }, } 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.isLabelersPref(pref) && + AppBskyActorDefs.validateLabelersPref(pref).success + ) { + // labelers preferences + prefs.moderationPrefs.labelers = BskyAgent.appLabelers + .map((did) => ({ did, labels: {} })) + .concat( + pref.labelers.map((labeler) => ({ + ...labeler, + 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) } @@ -363,6 +431,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 } @@ -370,6 +439,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 } @@ -380,8 +450,44 @@ export class BskyAgent extends AtpAgent { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { $type, ...v } = pref prefs.interests = { ...prefs.interests, ...v } + } else if ( + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success + ) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { $type, ...v } = pref + prefs.moderationPrefs.mutedWords = v.items + } else if ( + AppBskyActorDefs.isHiddenPostsPref(pref) && + AppBskyActorDefs.validateHiddenPostsPref(pref).success + ) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { $type, ...v } = pref + prefs.moderationPrefs.hiddenPosts = v.items } } + + // apply the label prefs + for (const pref of labelPrefs) { + if (pref.labelerDid) { + const labeler = prefs.moderationPrefs.labelers.find( + (labeler) => labeler.did === pref.labelerDid, + ) + if (!labeler) continue + labeler.labels[pref.label] = pref.visibility as LabelPreference + } else { + prefs.moderationPrefs.labels[pref.label] = + pref.visibility as LabelPreference + } + } + + prefs.moderationPrefs.labels = remapLegacyLabels( + prefs.moderationPrefs.labels, + ) + + // automatically configure the client + this.configureLabelersHeader(prefsArrayToLabelerDids(res.data.preferences)) + return prefs } @@ -441,37 +547,153 @@ 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, ) + let legacyLabelPref: AppBskyActorDefs.ContentLabelPref | undefined + if (labelPref) { labelPref.visibility = value } else { labelPref = { $type: 'app.bsky.actor.defs#contentLabelPref', label: key, + labelerDid, visibility: value, } } + + if (AppBskyActorDefs.isContentLabelPref(labelPref)) { + // is global + if (!labelPref.labelerDid) { + const legacyLabelValue = { + 'graphic-media': 'gore', + porn: 'nsfw', + sexual: 'suggestive', + }[labelPref.label] + + // if it's a legacy label, double-write the legacy label + if (legacyLabelValue) { + legacyLabelPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateContentLabelPref(pref).success && + pref.label === legacyLabelValue && + pref.labelerDid === undefined, + ) as AppBskyActorDefs.ContentLabelPref | undefined + + if (legacyLabelPref) { + legacyLabelPref.visibility = value + } else { + legacyLabelPref = { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: legacyLabelValue, + labelerDid: undefined, + visibility: value, + } + } + } + } + } + return prefs .filter( (pref) => - !AppBskyActorDefs.isContentLabelPref(pref) || pref.label !== key, + !AppBskyActorDefs.isContentLabelPref(pref) || + !(pref.label === key && pref.labelerDid === labelerDid), ) .concat([labelPref]) + .filter((pref) => { + if (!legacyLabelPref) return true + return ( + !AppBskyActorDefs.isContentLabelPref(pref) || + !( + pref.label === legacyLabelPref.label && + pref.labelerDid === undefined + ) + ) + }) + .concat(legacyLabelPref ? [legacyLabelPref] : []) }) } + async addLabeler(did: string) { + const prefs = await updatePreferences( + this, + (prefs: AppBskyActorDefs.Preferences) => { + let labelersPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isLabelersPref(pref) && + AppBskyActorDefs.validateLabelersPref(pref).success, + ) + if (!labelersPref) { + labelersPref = { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [], + } + } + if (AppBskyActorDefs.isLabelersPref(labelersPref)) { + let labelerPrefItem = labelersPref.labelers.find( + (labeler) => labeler.did === did, + ) + if (!labelerPrefItem) { + labelerPrefItem = { + did, + } + labelersPref.labelers.push(labelerPrefItem) + } + } + return prefs + .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref)) + .concat([labelersPref]) + }, + ) + // automatically configure the client + this.configureLabelersHeader(prefsArrayToLabelerDids(prefs)) + } + + async removeLabeler(did: string) { + const prefs = await updatePreferences( + this, + (prefs: AppBskyActorDefs.Preferences) => { + let labelersPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isLabelersPref(pref) && + AppBskyActorDefs.validateLabelersPref(pref).success, + ) + if (!labelersPref) { + labelersPref = { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [], + } + } + if (AppBskyActorDefs.isLabelersPref(labelersPref)) { + labelersPref.labelers = labelersPref.labelers.filter( + (labeler) => labeler.did !== did, + ) + } + return prefs + .filter((pref) => !AppBskyActorDefs.isLabelersPref(pref)) + .concat([labelersPref]) + }, + ) + // automatically configure the client + this.configureLabelersHeader(prefsArrayToLabelerDids(prefs)) + } + async setPersonalDetails({ birthDate, }: { @@ -548,6 +770,118 @@ export class BskyAgent extends AtpAgent { .concat([{ ...pref, $type: 'app.bsky.actor.defs#interestsPref' }]) }) } + + async upsertMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + let mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (const updatedWord of newMutedWords) { + let foundMatch = false + const sanitizedUpdatedValue = sanitizeMutedWordValue( + updatedWord.value, + ) + + // was trimmed down to an empty string e.g. single `#` + if (!sanitizedUpdatedValue) continue + + for (const existingItem of mutedWordsPref.items) { + if (existingItem.value === sanitizedUpdatedValue) { + existingItem.targets = Array.from( + new Set([...existingItem.targets, ...updatedWord.targets]), + ) + foundMatch = true + break + } + } + + if (!foundMatch) { + mutedWordsPref.items.push({ + ...updatedWord, + value: sanitizedUpdatedValue, + }) + } + } + } else { + // if the pref doesn't exist, create it + mutedWordsPref = { + items: newMutedWords.map((w) => ({ + ...w, + value: sanitizeMutedWordValue(w.value), + })), + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) + } + + async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + const mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (const existingItem of mutedWordsPref.items) { + if (existingItem.value === mutedWord.value) { + existingItem.targets = mutedWord.targets + break + } + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) + } + + async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) { + await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => { + const mutedWordsPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isMutedWordsPref(pref) && + AppBskyActorDefs.validateMutedWordsPref(pref).success, + ) + + if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) { + for (let i = 0; i < mutedWordsPref.items.length; i++) { + const existing = mutedWordsPref.items[i] + if (existing.value === mutedWord.value) { + mutedWordsPref.items.splice(i, 1) + break + } + } + } + + return prefs + .filter((p) => !AppBskyActorDefs.isMutedWordsPref(p)) + .concat([ + { ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' }, + ]) + }) + } + + async hidePost(postUri: string) { + await updateHiddenPost(this, postUri, 'hide') + } + + async unhidePost(postUri: string) { + await updateHiddenPost(this, postUri, 'unhide') + } } /** @@ -567,11 +901,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 } /** @@ -609,3 +944,106 @@ async function updateFeedPreferences( }) return res } + +/** + * Helper to transform the legacy content preferences. + */ +function adjustLegacyContentLabelPref( + pref: AppBskyActorDefs.ContentLabelPref, +): AppBskyActorDefs.ContentLabelPref { + let visibility = pref.visibility + + // adjust legacy values + if (visibility === 'show') { + visibility = 'ignore' + } + + return { ...pref, visibility } +} + +/** + * Re-maps legacy labels to new labels on READ. Does not save these changes to + * the user's preferences. + */ +function remapLegacyLabels( + labels: BskyPreferences['moderationPrefs']['labels'], +) { + const _labels = { ...labels } + const legacyToNewMap: Record = { + gore: 'graphic-media', + nsfw: 'porn', + suggestive: 'sexual', + } + + for (const labelName in _labels) { + const newLabelName = legacyToNewMap[labelName]! + if (newLabelName) { + _labels[newLabelName] = _labels[labelName] + } + } + + return _labels +} + +/** + * A helper to get the currently enabled labelers from the full preferences array + */ +function prefsArrayToLabelerDids( + prefs: AppBskyActorDefs.Preferences, +): string[] { + const labelersPref = prefs.findLast( + (pref) => + AppBskyActorDefs.isLabelersPref(pref) && + AppBskyActorDefs.validateLabelersPref(pref).success, + ) + let dids: string[] = [] + if (labelersPref) { + dids = (labelersPref as AppBskyActorDefs.LabelersPref).labelers.map( + (labeler) => labeler.did, + ) + } + return dids +} + +async function updateHiddenPost( + agent: BskyAgent, + postUri: string, + action: 'hide' | 'unhide', +) { + await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => { + let pref = prefs.findLast( + (pref) => + AppBskyActorDefs.isHiddenPostsPref(pref) && + AppBskyActorDefs.validateHiddenPostsPref(pref).success, + ) + if (pref && AppBskyActorDefs.isHiddenPostsPref(pref)) { + pref.items = + action === 'hide' + ? Array.from(new Set([...pref.items, postUri])) + : pref.items.filter((uri) => uri !== postUri) + } else { + if (action === 'hide') { + pref = { + $type: 'app.bsky.actor.defs#hiddenPostsPref', + items: [postUri], + } + } + } + return prefs + .filter((p) => !AppBskyActorDefs.isInterestsPref(p)) + .concat([{ ...pref, $type: 'app.bsky.actor.defs#hiddenPostsPref' }]) + }) +} + +function isBskyPrefs(v: any): v is BskyPreferences { + return ( + v && + typeof v === 'object' && + 'moderationPrefs' in v && + isModPrefs(v.moderationPrefs) + ) +} + +function isModPrefs(v: any): v is ModerationPrefs { + return v && typeof v === 'object' && 'labelers' in v +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 35c784cbea3..924244e3ced 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -7,31 +7,25 @@ import { } from '@atproto/xrpc' import { schemas } from './lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' -import * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' -import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' -import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' -import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' -import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' -import * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' -import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' -import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' -import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' -import * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' +import * as ComAtprotoAdminUpdateAccountPassword from './types/com/atproto/admin/updateAccountPassword' import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' +import * as ComAtprotoIdentityGetRecommendedDidCredentials from './types/com/atproto/identity/getRecommendedDidCredentials' +import * as ComAtprotoIdentityRequestPlcOperationSignature from './types/com/atproto/identity/requestPlcOperationSignature' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' +import * as ComAtprotoIdentitySignPlcOperation from './types/com/atproto/identity/signPlcOperation' +import * as ComAtprotoIdentitySubmitPlcOperation from './types/com/atproto/identity/submitPlcOperation' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' import * as ComAtprotoLabelQueryLabels from './types/com/atproto/label/queryLabels' @@ -43,21 +37,27 @@ import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createReco import * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord' import * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo' import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' +import * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo' +import * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs' import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerActivateAccount from './types/com/atproto/server/activateAccount' +import * as ComAtprotoServerCheckAccountStatus from './types/com/atproto/server/checkAccountStatus' import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' import * as ComAtprotoServerCreateInviteCodes from './types/com/atproto/server/createInviteCodes' import * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' +import * as ComAtprotoServerDeactivateAccount from './types/com/atproto/server/deactivateAccount' import * as ComAtprotoServerDefs from './types/com/atproto/server/defs' import * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' +import * as ComAtprotoServerGetServiceAuth from './types/com/atproto/server/getServiceAuth' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' @@ -83,10 +83,7 @@ import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' import * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' import * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -import * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -import * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' import * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -import * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' import * as AppBskyActorDefs from './types/app/bsky/actor/defs' import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' @@ -142,6 +139,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' @@ -152,32 +152,39 @@ import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unsp import * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' +import * as ToolsOzoneCommunicationCreateTemplate from './types/tools/ozone/communication/createTemplate' +import * as ToolsOzoneCommunicationDefs from './types/tools/ozone/communication/defs' +import * as ToolsOzoneCommunicationDeleteTemplate from './types/tools/ozone/communication/deleteTemplate' +import * as ToolsOzoneCommunicationListTemplates from './types/tools/ozone/communication/listTemplates' +import * as ToolsOzoneCommunicationUpdateTemplate from './types/tools/ozone/communication/updateTemplate' +import * as ToolsOzoneModerationDefs from './types/tools/ozone/moderation/defs' +import * as ToolsOzoneModerationEmitEvent from './types/tools/ozone/moderation/emitEvent' +import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/getEvent' +import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' +import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' +import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' +import * as ToolsOzoneModerationSearchRepos from './types/tools/ozone/moderation/searchRepos' -export * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' export * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' -export * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' -export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' export * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' -export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' -export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' -export * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' export * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' -export * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' -export * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' -export * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' -export * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' export * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' export * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' export * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' -export * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' +export * as ComAtprotoAdminUpdateAccountPassword from './types/com/atproto/admin/updateAccountPassword' export * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' +export * as ComAtprotoIdentityGetRecommendedDidCredentials from './types/com/atproto/identity/getRecommendedDidCredentials' +export * as ComAtprotoIdentityRequestPlcOperationSignature from './types/com/atproto/identity/requestPlcOperationSignature' export * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' +export * as ComAtprotoIdentitySignPlcOperation from './types/com/atproto/identity/signPlcOperation' +export * as ComAtprotoIdentitySubmitPlcOperation from './types/com/atproto/identity/submitPlcOperation' export * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' export * as ComAtprotoLabelQueryLabels from './types/com/atproto/label/queryLabels' @@ -189,21 +196,27 @@ export * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createReco export * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord' export * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo' export * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' +export * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo' +export * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs' export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +export * as ComAtprotoServerActivateAccount from './types/com/atproto/server/activateAccount' +export * as ComAtprotoServerCheckAccountStatus from './types/com/atproto/server/checkAccountStatus' export * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' export * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' export * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' export * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' export * as ComAtprotoServerCreateInviteCodes from './types/com/atproto/server/createInviteCodes' export * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' +export * as ComAtprotoServerDeactivateAccount from './types/com/atproto/server/deactivateAccount' export * as ComAtprotoServerDefs from './types/com/atproto/server/defs' export * as ComAtprotoServerDeleteAccount from './types/com/atproto/server/deleteAccount' export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/deleteSession' export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' export * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' +export * as ComAtprotoServerGetServiceAuth from './types/com/atproto/server/getServiceAuth' export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' export * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' @@ -229,10 +242,7 @@ export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' export * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' export * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -export * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -export * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' export * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -export * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' export * as AppBskyActorDefs from './types/app/bsky/actor/defs' export * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' export * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' @@ -288,6 +298,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' @@ -298,12 +311,20 @@ export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unsp export * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' export * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' export * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' +export * as ToolsOzoneCommunicationCreateTemplate from './types/tools/ozone/communication/createTemplate' +export * as ToolsOzoneCommunicationDefs from './types/tools/ozone/communication/defs' +export * as ToolsOzoneCommunicationDeleteTemplate from './types/tools/ozone/communication/deleteTemplate' +export * as ToolsOzoneCommunicationListTemplates from './types/tools/ozone/communication/listTemplates' +export * as ToolsOzoneCommunicationUpdateTemplate from './types/tools/ozone/communication/updateTemplate' +export * as ToolsOzoneModerationDefs from './types/tools/ozone/moderation/defs' +export * as ToolsOzoneModerationEmitEvent from './types/tools/ozone/moderation/emitEvent' +export * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/getEvent' +export * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord' +export * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo' +export * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents' +export * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses' +export * as ToolsOzoneModerationSearchRepos from './types/tools/ozone/moderation/searchRepos' -export const COM_ATPROTO_ADMIN = { - DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', - DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', - DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', -} export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', DefsReasonViolation: 'com.atproto.moderation.defs#reasonViolation', @@ -317,6 +338,12 @@ export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', DefsCuratelist: 'app.bsky.graph.defs#curatelist', } +export const TOOLS_OZONE_MODERATION = { + DefsReviewOpen: 'tools.ozone.moderation.defs#reviewOpen', + DefsReviewEscalated: 'tools.ozone.moderation.defs#reviewEscalated', + DefsReviewClosed: 'tools.ozone.moderation.defs#reviewClosed', + DefsReviewNone: 'tools.ozone.moderation.defs#reviewNone', +} export class AtpBaseClient { xrpc: XrpcClient = new XrpcClient() @@ -335,12 +362,14 @@ export class AtpServiceClient { xrpc: XrpcServiceClient com: ComNS app: AppNS + tools: ToolsNS constructor(baseClient: AtpBaseClient, xrpcService: XrpcServiceClient) { this._baseClient = baseClient this.xrpc = xrpcService this.com = new ComNS(this) this.app = new AppNS(this) + this.tools = new ToolsNS(this) } setHeader(key: string, value: string): void { @@ -389,22 +418,6 @@ export class ComAtprotoAdminNS { this._service = service } - createCommunicationTemplate( - data?: ComAtprotoAdminCreateCommunicationTemplate.InputSchema, - opts?: ComAtprotoAdminCreateCommunicationTemplate.CallOptions, - ): Promise { - return this._service.xrpc - .call( - 'com.atproto.admin.createCommunicationTemplate', - opts?.qp, - data, - opts, - ) - .catch((e) => { - throw ComAtprotoAdminCreateCommunicationTemplate.toKnownErr(e) - }) - } - deleteAccount( data?: ComAtprotoAdminDeleteAccount.InputSchema, opts?: ComAtprotoAdminDeleteAccount.CallOptions, @@ -416,22 +429,6 @@ export class ComAtprotoAdminNS { }) } - deleteCommunicationTemplate( - data?: ComAtprotoAdminDeleteCommunicationTemplate.InputSchema, - opts?: ComAtprotoAdminDeleteCommunicationTemplate.CallOptions, - ): Promise { - return this._service.xrpc - .call( - 'com.atproto.admin.deleteCommunicationTemplate', - opts?.qp, - data, - opts, - ) - .catch((e) => { - throw ComAtprotoAdminDeleteCommunicationTemplate.toKnownErr(e) - }) - } - disableAccountInvites( data?: ComAtprotoAdminDisableAccountInvites.InputSchema, opts?: ComAtprotoAdminDisableAccountInvites.CallOptions, @@ -454,17 +451,6 @@ export class ComAtprotoAdminNS { }) } - emitModerationEvent( - data?: ComAtprotoAdminEmitModerationEvent.InputSchema, - opts?: ComAtprotoAdminEmitModerationEvent.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.emitModerationEvent', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoAdminEmitModerationEvent.toKnownErr(e) - }) - } - enableAccountInvites( data?: ComAtprotoAdminEnableAccountInvites.InputSchema, opts?: ComAtprotoAdminEnableAccountInvites.CallOptions, @@ -509,39 +495,6 @@ export class ComAtprotoAdminNS { }) } - getModerationEvent( - params?: ComAtprotoAdminGetModerationEvent.QueryParams, - opts?: ComAtprotoAdminGetModerationEvent.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.getModerationEvent', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminGetModerationEvent.toKnownErr(e) - }) - } - - getRecord( - params?: ComAtprotoAdminGetRecord.QueryParams, - opts?: ComAtprotoAdminGetRecord.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.getRecord', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminGetRecord.toKnownErr(e) - }) - } - - getRepo( - params?: ComAtprotoAdminGetRepo.QueryParams, - opts?: ComAtprotoAdminGetRepo.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.getRepo', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminGetRepo.toKnownErr(e) - }) - } - getSubjectStatus( params?: ComAtprotoAdminGetSubjectStatus.QueryParams, opts?: ComAtprotoAdminGetSubjectStatus.CallOptions, @@ -553,60 +506,6 @@ export class ComAtprotoAdminNS { }) } - listCommunicationTemplates( - params?: ComAtprotoAdminListCommunicationTemplates.QueryParams, - opts?: ComAtprotoAdminListCommunicationTemplates.CallOptions, - ): Promise { - return this._service.xrpc - .call( - 'com.atproto.admin.listCommunicationTemplates', - params, - undefined, - opts, - ) - .catch((e) => { - throw ComAtprotoAdminListCommunicationTemplates.toKnownErr(e) - }) - } - - queryModerationEvents( - params?: ComAtprotoAdminQueryModerationEvents.QueryParams, - opts?: ComAtprotoAdminQueryModerationEvents.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.queryModerationEvents', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminQueryModerationEvents.toKnownErr(e) - }) - } - - queryModerationStatuses( - params?: ComAtprotoAdminQueryModerationStatuses.QueryParams, - opts?: ComAtprotoAdminQueryModerationStatuses.CallOptions, - ): Promise { - return this._service.xrpc - .call( - 'com.atproto.admin.queryModerationStatuses', - params, - undefined, - opts, - ) - .catch((e) => { - throw ComAtprotoAdminQueryModerationStatuses.toKnownErr(e) - }) - } - - searchRepos( - params?: ComAtprotoAdminSearchRepos.QueryParams, - opts?: ComAtprotoAdminSearchRepos.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.admin.searchRepos', params, undefined, opts) - .catch((e) => { - throw ComAtprotoAdminSearchRepos.toKnownErr(e) - }) - } - sendEmail( data?: ComAtprotoAdminSendEmail.InputSchema, opts?: ComAtprotoAdminSendEmail.CallOptions, @@ -640,19 +539,14 @@ export class ComAtprotoAdminNS { }) } - updateCommunicationTemplate( - data?: ComAtprotoAdminUpdateCommunicationTemplate.InputSchema, - opts?: ComAtprotoAdminUpdateCommunicationTemplate.CallOptions, - ): Promise { + updateAccountPassword( + data?: ComAtprotoAdminUpdateAccountPassword.InputSchema, + opts?: ComAtprotoAdminUpdateAccountPassword.CallOptions, + ): Promise { return this._service.xrpc - .call( - 'com.atproto.admin.updateCommunicationTemplate', - opts?.qp, - data, - opts, - ) + .call('com.atproto.admin.updateAccountPassword', opts?.qp, data, opts) .catch((e) => { - throw ComAtprotoAdminUpdateCommunicationTemplate.toKnownErr(e) + throw ComAtprotoAdminUpdateAccountPassword.toKnownErr(e) }) } @@ -675,6 +569,38 @@ export class ComAtprotoIdentityNS { this._service = service } + getRecommendedDidCredentials( + params?: ComAtprotoIdentityGetRecommendedDidCredentials.QueryParams, + opts?: ComAtprotoIdentityGetRecommendedDidCredentials.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.identity.getRecommendedDidCredentials', + params, + undefined, + opts, + ) + .catch((e) => { + throw ComAtprotoIdentityGetRecommendedDidCredentials.toKnownErr(e) + }) + } + + requestPlcOperationSignature( + data?: ComAtprotoIdentityRequestPlcOperationSignature.InputSchema, + opts?: ComAtprotoIdentityRequestPlcOperationSignature.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.identity.requestPlcOperationSignature', + opts?.qp, + data, + opts, + ) + .catch((e) => { + throw ComAtprotoIdentityRequestPlcOperationSignature.toKnownErr(e) + }) + } + resolveHandle( params?: ComAtprotoIdentityResolveHandle.QueryParams, opts?: ComAtprotoIdentityResolveHandle.CallOptions, @@ -686,6 +612,28 @@ export class ComAtprotoIdentityNS { }) } + signPlcOperation( + data?: ComAtprotoIdentitySignPlcOperation.InputSchema, + opts?: ComAtprotoIdentitySignPlcOperation.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.identity.signPlcOperation', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoIdentitySignPlcOperation.toKnownErr(e) + }) + } + + submitPlcOperation( + data?: ComAtprotoIdentitySubmitPlcOperation.InputSchema, + opts?: ComAtprotoIdentitySubmitPlcOperation.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.identity.submitPlcOperation', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoIdentitySubmitPlcOperation.toKnownErr(e) + }) + } + updateHandle( data?: ComAtprotoIdentityUpdateHandle.InputSchema, opts?: ComAtprotoIdentityUpdateHandle.CallOptions, @@ -798,6 +746,28 @@ export class ComAtprotoRepoNS { }) } + importRepo( + data?: ComAtprotoRepoImportRepo.InputSchema, + opts?: ComAtprotoRepoImportRepo.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.repo.importRepo', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoRepoImportRepo.toKnownErr(e) + }) + } + + listMissingBlobs( + params?: ComAtprotoRepoListMissingBlobs.QueryParams, + opts?: ComAtprotoRepoListMissingBlobs.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.repo.listMissingBlobs', params, undefined, opts) + .catch((e) => { + throw ComAtprotoRepoListMissingBlobs.toKnownErr(e) + }) + } + listRecords( params?: ComAtprotoRepoListRecords.QueryParams, opts?: ComAtprotoRepoListRecords.CallOptions, @@ -839,6 +809,28 @@ export class ComAtprotoServerNS { this._service = service } + activateAccount( + data?: ComAtprotoServerActivateAccount.InputSchema, + opts?: ComAtprotoServerActivateAccount.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.activateAccount', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerActivateAccount.toKnownErr(e) + }) + } + + checkAccountStatus( + params?: ComAtprotoServerCheckAccountStatus.QueryParams, + opts?: ComAtprotoServerCheckAccountStatus.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.checkAccountStatus', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerCheckAccountStatus.toKnownErr(e) + }) + } + confirmEmail( data?: ComAtprotoServerConfirmEmail.InputSchema, opts?: ComAtprotoServerConfirmEmail.CallOptions, @@ -905,6 +897,17 @@ export class ComAtprotoServerNS { }) } + deactivateAccount( + data?: ComAtprotoServerDeactivateAccount.InputSchema, + opts?: ComAtprotoServerDeactivateAccount.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.deactivateAccount', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerDeactivateAccount.toKnownErr(e) + }) + } + deleteAccount( data?: ComAtprotoServerDeleteAccount.InputSchema, opts?: ComAtprotoServerDeleteAccount.CallOptions, @@ -949,6 +952,17 @@ export class ComAtprotoServerNS { }) } + getServiceAuth( + params?: ComAtprotoServerGetServiceAuth.QueryParams, + opts?: ComAtprotoServerGetServiceAuth.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.getServiceAuth', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerGetServiceAuth.toKnownErr(e) + }) + } + getSession( params?: ComAtprotoServerGetSession.QueryParams, opts?: ComAtprotoServerGetSession.CallOptions, @@ -1229,28 +1243,6 @@ export class ComAtprotoTempNS { }) } - importRepo( - data?: ComAtprotoTempImportRepo.InputSchema, - opts?: ComAtprotoTempImportRepo.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.importRepo', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempImportRepo.toKnownErr(e) - }) - } - - pushBlob( - data?: ComAtprotoTempPushBlob.InputSchema, - opts?: ComAtprotoTempPushBlob.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.pushBlob', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempPushBlob.toKnownErr(e) - }) - } - requestPhoneVerification( data?: ComAtprotoTempRequestPhoneVerification.InputSchema, opts?: ComAtprotoTempRequestPhoneVerification.CallOptions, @@ -1261,17 +1253,6 @@ export class ComAtprotoTempNS { throw ComAtprotoTempRequestPhoneVerification.toKnownErr(e) }) } - - transferAccount( - data?: ComAtprotoTempTransferAccount.InputSchema, - opts?: ComAtprotoTempTransferAccount.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.transferAccount', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempTransferAccount.toKnownErr(e) - }) - } } export class AppNS { @@ -1290,6 +1271,7 @@ export class AppBskyNS { embed: AppBskyEmbedNS feed: AppBskyFeedNS graph: AppBskyGraphNS + labeler: AppBskyLabelerNS notification: AppBskyNotificationNS richtext: AppBskyRichtextNS unspecced: AppBskyUnspeccedNS @@ -1300,6 +1282,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) @@ -2451,6 +2434,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 @@ -2567,3 +2641,162 @@ export class AppBskyUnspeccedNS { }) } } + +export class ToolsNS { + _service: AtpServiceClient + ozone: ToolsOzoneNS + + constructor(service: AtpServiceClient) { + this._service = service + this.ozone = new ToolsOzoneNS(service) + } +} + +export class ToolsOzoneNS { + _service: AtpServiceClient + communication: ToolsOzoneCommunicationNS + moderation: ToolsOzoneModerationNS + + constructor(service: AtpServiceClient) { + this._service = service + this.communication = new ToolsOzoneCommunicationNS(service) + this.moderation = new ToolsOzoneModerationNS(service) + } +} + +export class ToolsOzoneCommunicationNS { + _service: AtpServiceClient + + constructor(service: AtpServiceClient) { + this._service = service + } + + createTemplate( + data?: ToolsOzoneCommunicationCreateTemplate.InputSchema, + opts?: ToolsOzoneCommunicationCreateTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.communication.createTemplate', opts?.qp, data, opts) + .catch((e) => { + throw ToolsOzoneCommunicationCreateTemplate.toKnownErr(e) + }) + } + + deleteTemplate( + data?: ToolsOzoneCommunicationDeleteTemplate.InputSchema, + opts?: ToolsOzoneCommunicationDeleteTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.communication.deleteTemplate', opts?.qp, data, opts) + .catch((e) => { + throw ToolsOzoneCommunicationDeleteTemplate.toKnownErr(e) + }) + } + + listTemplates( + params?: ToolsOzoneCommunicationListTemplates.QueryParams, + opts?: ToolsOzoneCommunicationListTemplates.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.communication.listTemplates', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneCommunicationListTemplates.toKnownErr(e) + }) + } + + updateTemplate( + data?: ToolsOzoneCommunicationUpdateTemplate.InputSchema, + opts?: ToolsOzoneCommunicationUpdateTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.communication.updateTemplate', opts?.qp, data, opts) + .catch((e) => { + throw ToolsOzoneCommunicationUpdateTemplate.toKnownErr(e) + }) + } +} + +export class ToolsOzoneModerationNS { + _service: AtpServiceClient + + constructor(service: AtpServiceClient) { + this._service = service + } + + emitEvent( + data?: ToolsOzoneModerationEmitEvent.InputSchema, + opts?: ToolsOzoneModerationEmitEvent.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.emitEvent', opts?.qp, data, opts) + .catch((e) => { + throw ToolsOzoneModerationEmitEvent.toKnownErr(e) + }) + } + + getEvent( + params?: ToolsOzoneModerationGetEvent.QueryParams, + opts?: ToolsOzoneModerationGetEvent.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.getEvent', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationGetEvent.toKnownErr(e) + }) + } + + getRecord( + params?: ToolsOzoneModerationGetRecord.QueryParams, + opts?: ToolsOzoneModerationGetRecord.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.getRecord', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationGetRecord.toKnownErr(e) + }) + } + + getRepo( + params?: ToolsOzoneModerationGetRepo.QueryParams, + opts?: ToolsOzoneModerationGetRepo.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.getRepo', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationGetRepo.toKnownErr(e) + }) + } + + queryEvents( + params?: ToolsOzoneModerationQueryEvents.QueryParams, + opts?: ToolsOzoneModerationQueryEvents.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.queryEvents', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationQueryEvents.toKnownErr(e) + }) + } + + queryStatuses( + params?: ToolsOzoneModerationQueryStatuses.QueryParams, + opts?: ToolsOzoneModerationQueryStatuses.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.queryStatuses', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationQueryStatuses.toKnownErr(e) + }) + } + + searchRepos( + params?: ToolsOzoneModerationSearchRepos.QueryParams, + opts?: ToolsOzoneModerationSearchRepos.CallOptions, + ): Promise { + return this._service.xrpc + .call('tools.ozone.moderation.searchRepos', params, undefined, opts) + .catch((e) => { + throw ToolsOzoneModerationSearchRepos.toKnownErr(e) + }) + } +} diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 08a70f8ca1d..ba51eb63aa2 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4,51 +4,6 @@ import { LexiconDoc, Lexicons } from '@atproto/lexicon' export const schemaDict = { - ComAtprotoAdminCreateCommunicationTemplate: { - lexicon: 1, - id: 'com.atproto.admin.createCommunicationTemplate', - defs: { - main: { - type: 'procedure', - description: - 'Administrative action to create a new, re-usable communication (email for now) template.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['subject', 'contentMarkdown', 'name'], - properties: { - name: { - type: 'string', - description: 'Name of the template.', - }, - contentMarkdown: { - type: 'string', - description: - 'Content of the template, markdown supported, can contain variable placeholders.', - }, - subject: { - type: 'string', - description: 'Subject of the message, used in emails.', - }, - createdBy: { - type: 'string', - format: 'did', - description: 'DID of the user who is creating the template.', - }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#communicationTemplateView', - }, - }, - }, - }, - }, ComAtprotoAdminDefs: { lexicon: 1, id: 'com.atproto.admin.defs', @@ -65,980 +20,960 @@ export const schemaDict = { }, }, }, - modEventView: { + accountView: { type: 'object', - required: [ - 'id', - 'event', - 'subject', - 'subjectBlobCids', - 'createdBy', - 'createdAt', - ], + required: ['did', 'handle', 'indexedAt'], properties: { - id: { - type: 'integer', + did: { + type: 'string', + format: 'did', }, - event: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#modEventTakedown', - 'lex:com.atproto.admin.defs#modEventReverseTakedown', - 'lex:com.atproto.admin.defs#modEventComment', - 'lex:com.atproto.admin.defs#modEventReport', - 'lex:com.atproto.admin.defs#modEventLabel', - 'lex:com.atproto.admin.defs#modEventAcknowledge', - 'lex:com.atproto.admin.defs#modEventEscalate', - 'lex:com.atproto.admin.defs#modEventMute', - 'lex:com.atproto.admin.defs#modEventEmail', - ], + handle: { + type: 'string', + format: 'handle', }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], + email: { + type: 'string', }, - subjectBlobCids: { + relatedRecords: { type: 'array', items: { - type: 'string', + type: 'unknown', }, }, - createdBy: { - type: 'string', - format: 'did', - }, - createdAt: { + indexedAt: { type: 'string', format: 'datetime', }, - creatorHandle: { - type: 'string', - }, - subjectHandle: { - type: 'string', - }, - }, - }, - modEventViewDetail: { - type: 'object', - required: [ - 'id', - 'event', - 'subject', - 'subjectBlobs', - 'createdBy', - 'createdAt', - ], - properties: { - id: { - type: 'integer', - }, - event: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#modEventTakedown', - 'lex:com.atproto.admin.defs#modEventReverseTakedown', - 'lex:com.atproto.admin.defs#modEventComment', - 'lex:com.atproto.admin.defs#modEventReport', - 'lex:com.atproto.admin.defs#modEventLabel', - 'lex:com.atproto.admin.defs#modEventAcknowledge', - 'lex:com.atproto.admin.defs#modEventEscalate', - 'lex:com.atproto.admin.defs#modEventMute', - 'lex:com.atproto.admin.defs#modEventResolveAppeal', - ], - }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoView', - 'lex:com.atproto.admin.defs#repoViewNotFound', - 'lex:com.atproto.admin.defs#recordView', - 'lex:com.atproto.admin.defs#recordViewNotFound', - ], + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', }, - subjectBlobs: { + invites: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.admin.defs#blobView', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, - createdBy: { - type: 'string', - format: 'did', + invitesDisabled: { + type: 'boolean', }, - createdAt: { + emailConfirmedAt: { type: 'string', format: 'datetime', }, + inviteNote: { + type: 'string', + }, }, }, - reportView: { + repoRef: { type: 'object', - required: [ - 'id', - 'reasonType', - 'subject', - 'reportedBy', - 'createdAt', - 'resolvedByActionIds', - ], + required: ['did'], properties: { - id: { - type: 'integer', - }, - reasonType: { - type: 'ref', - ref: 'lex:com.atproto.moderation.defs#reasonType', - }, - comment: { - type: 'string', - }, - subjectRepoHandle: { - type: 'string', - }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], - }, - reportedBy: { + did: { type: 'string', format: 'did', }, - createdAt: { - type: 'string', - format: 'datetime', - }, - resolvedByActionIds: { - type: 'array', - items: { - type: 'integer', - }, - }, }, }, - subjectStatusView: { + repoBlobRef: { type: 'object', - required: ['id', 'subject', 'createdAt', 'updatedAt', 'reviewState'], + required: ['did', 'cid'], properties: { - id: { - type: 'integer', - }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], - }, - subjectBlobCids: { - type: 'array', - items: { - type: 'string', - format: 'cid', - }, - }, - subjectRepoHandle: { - type: 'string', - }, - updatedAt: { - type: 'string', - format: 'datetime', - description: - 'Timestamp referencing when the last update was made to the moderation status of the subject', - }, - createdAt: { - type: 'string', - format: 'datetime', - description: - 'Timestamp referencing the first moderation status impacting event was emitted on the subject', - }, - reviewState: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#subjectReviewState', - }, - comment: { - type: 'string', - description: 'Sticky comment on the subject.', - }, - muteUntil: { - type: 'string', - format: 'datetime', - }, - lastReviewedBy: { + did: { type: 'string', format: 'did', }, - lastReviewedAt: { - type: 'string', - format: 'datetime', - }, - lastReportedAt: { - type: 'string', - format: 'datetime', - }, - lastAppealedAt: { + cid: { type: 'string', - format: 'datetime', - description: - 'Timestamp referencing when the author of the subject appealed a moderation action', - }, - takendown: { - type: 'boolean', - }, - appealed: { - type: 'boolean', - description: - 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + format: 'cid', }, - suspendUntil: { + recordUri: { type: 'string', - format: 'datetime', + format: 'at-uri', }, }, }, - reportViewDetail: { - type: 'object', - required: [ - 'id', - 'reasonType', - 'subject', - 'reportedBy', - 'createdAt', - 'resolvedByActions', - ], - properties: { - id: { - type: 'integer', - }, - reasonType: { - type: 'ref', - ref: 'lex:com.atproto.moderation.defs#reasonType', - }, - comment: { - type: 'string', - }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoView', - 'lex:com.atproto.admin.defs#repoViewNotFound', - 'lex:com.atproto.admin.defs#recordView', - 'lex:com.atproto.admin.defs#recordViewNotFound', - ], - }, - subjectStatus: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#subjectStatusView', - }, - reportedBy: { - type: 'string', - format: 'did', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - resolvedByActions: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#modEventView', + }, + }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, }, }, }, }, - repoView: { - type: 'object', - required: [ - 'did', - 'handle', - 'relatedRecords', - 'indexedAt', - 'moderation', - ], - properties: { - did: { - type: 'string', - format: 'did', - }, - handle: { - type: 'string', - format: 'handle', - }, - email: { - type: 'string', - }, - relatedRecords: { - type: 'array', - items: { - type: 'unknown', + }, + }, + ComAtprotoAdminDisableAccountInvites: { + lexicon: 1, + id: 'com.atproto.admin.disableAccountInvites', + defs: { + main: { + type: 'procedure', + description: + 'Disable an account from receiving new invite codes, but does not invalidate existing codes.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['account'], + properties: { + account: { + type: 'string', + format: 'did', + }, + note: { + type: 'string', + description: 'Optional reason for disabled invites.', + }, }, }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - moderation: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#moderation', - }, - invitedBy: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', - }, - invitesDisabled: { - type: 'boolean', - }, - inviteNote: { - type: 'string', - }, }, }, - repoViewDetail: { - type: 'object', - required: [ - 'did', - 'handle', - 'relatedRecords', - 'indexedAt', - 'moderation', - ], - properties: { - did: { - type: 'string', - format: 'did', - }, - handle: { - type: 'string', - format: 'handle', - }, - email: { - type: 'string', - }, - relatedRecords: { - type: 'array', - items: { - type: 'unknown', + }, + }, + ComAtprotoAdminDisableInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.disableInviteCodes', + defs: { + main: { + type: 'procedure', + description: + 'Disable some set of codes and/or all codes associated with a set of users.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + codes: { + type: 'array', + items: { + type: 'string', + }, + }, + accounts: { + type: 'array', + items: { + type: 'string', + }, + }, }, }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - moderation: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#moderationDetail', + }, + }, + }, + }, + ComAtprotoAdminEnableAccountInvites: { + lexicon: 1, + id: 'com.atproto.admin.enableAccountInvites', + defs: { + main: { + type: 'procedure', + description: "Re-enable an account's ability to receive invite codes.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['account'], + properties: { + account: { + type: 'string', + format: 'did', + }, + note: { + type: 'string', + description: 'Optional reason for enabled invites.', + }, + }, }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + ComAtprotoAdminGetAccountInfo: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfo', + defs: { + main: { + type: 'query', + description: 'Get details about an account.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', }, }, - invitedBy: { + }, + output: { + encoding: 'application/json', + schema: { type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', + ref: 'lex:com.atproto.admin.defs#accountView', }, - invites: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + }, + }, + ComAtprotoAdminGetAccountInfos: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfos', + defs: { + main: { + type: 'query', + description: 'Get details about some accounts.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, }, }, - invitesDisabled: { - type: 'boolean', - }, - inviteNote: { - type: 'string', - }, - emailConfirmedAt: { - type: 'string', - format: 'datetime', - }, }, - }, - accountView: { - type: 'object', - required: ['did', 'handle', 'indexedAt'], - properties: { - did: { - type: 'string', - format: 'did', - }, - handle: { - type: 'string', - format: 'handle', - }, - email: { - type: 'string', - }, - relatedRecords: { - type: 'array', - items: { - type: 'unknown', - }, - }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - invitedBy: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', - }, - invites: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['infos'], + properties: { + infos: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, }, }, - invitesDisabled: { - type: 'boolean', - }, - emailConfirmedAt: { - type: 'string', - format: 'datetime', - }, - inviteNote: { - type: 'string', - }, }, }, - repoViewNotFound: { - type: 'object', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - }, - }, - }, - repoRef: { - type: 'object', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', + }, + }, + ComAtprotoAdminGetInviteCodes: { + lexicon: 1, + id: 'com.atproto.admin.getInviteCodes', + defs: { + main: { + type: 'query', + description: 'Get an admin view of invite codes.', + parameters: { + type: 'params', + properties: { + sort: { + type: 'string', + knownValues: ['recent', 'usage'], + default: 'recent', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 500, + default: 100, + }, + cursor: { + type: 'string', + }, }, }, - }, - repoBlobRef: { - type: 'object', - required: ['did', 'cid'], - properties: { - did: { - type: 'string', - format: 'did', - }, - cid: { - type: 'string', - format: 'cid', - }, - recordUri: { - type: 'string', - format: 'at-uri', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + cursor: { + type: 'string', + }, + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + }, }, }, }, - recordView: { - type: 'object', - required: [ - 'uri', - 'cid', - 'value', - 'blobCids', - 'indexedAt', - 'moderation', - 'repo', - ], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - value: { - type: 'unknown', - }, - blobCids: { - type: 'array', - items: { + }, + }, + ComAtprotoAdminGetSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.getSubjectStatus', + defs: { + main: { + type: 'query', + description: + 'Get the service-specific admin status of a subject (account, record, or blob).', + parameters: { + type: 'params', + properties: { + did: { + type: 'string', + format: 'did', + }, + uri: { + type: 'string', + format: 'at-uri', + }, + blob: { type: 'string', format: 'cid', }, }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - moderation: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#moderation', - }, - repo: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#repoView', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, }, }, }, - recordViewDetail: { - type: 'object', - required: [ - 'uri', - 'cid', - 'value', - 'blobs', - 'indexedAt', - 'moderation', - 'repo', - ], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - value: { - type: 'unknown', - }, - blobs: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#blobView', + }, + }, + ComAtprotoAdminSendEmail: { + lexicon: 1, + id: 'com.atproto.admin.sendEmail', + defs: { + main: { + type: 'procedure', + description: "Send email to a user's account email address.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['recipientDid', 'content', 'senderDid'], + properties: { + recipientDid: { + type: 'string', + format: 'did', + }, + content: { + type: 'string', + }, + subject: { + type: 'string', + }, + senderDid: { + type: 'string', + format: 'did', + }, + comment: { + type: 'string', + description: + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + }, }, }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['sent'], + properties: { + sent: { + type: 'boolean', + }, }, }, - indexedAt: { - type: 'string', - format: 'datetime', + }, + }, + }, + }, + ComAtprotoAdminUpdateAccountEmail: { + lexicon: 1, + id: 'com.atproto.admin.updateAccountEmail', + defs: { + main: { + type: 'procedure', + description: "Administrative action to update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['account', 'email'], + properties: { + account: { + type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repo.', + }, + email: { + type: 'string', + }, + }, }, - moderation: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#moderationDetail', + }, + }, + }, + }, + ComAtprotoAdminUpdateAccountHandle: { + lexicon: 1, + id: 'com.atproto.admin.updateAccountHandle', + defs: { + main: { + type: 'procedure', + description: "Administrative action to update an account's handle.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + }, }, - repo: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#repoView', + }, + }, + }, + }, + ComAtprotoAdminUpdateAccountPassword: { + lexicon: 1, + id: 'com.atproto.admin.updateAccountPassword', + defs: { + main: { + type: 'procedure', + description: + 'Update the password for a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did', 'password'], + properties: { + did: { + type: 'string', + format: 'did', + }, + password: { + type: 'string', + }, + }, }, }, }, - recordViewNotFound: { - type: 'object', - required: ['uri'], - properties: { - uri: { - type: 'string', - format: 'at-uri', + }, + }, + ComAtprotoAdminUpdateSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.updateSubjectStatus', + defs: { + main: { + type: 'procedure', + description: + 'Update the service-specific admin status of a subject (account, record, or blob).', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, }, }, - }, - moderation: { - type: 'object', - properties: { - subjectStatus: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#subjectStatusView', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, }, }, }, - moderationDetail: { - type: 'object', - properties: { - subjectStatus: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#subjectStatusView', + }, + }, + ComAtprotoIdentityGetRecommendedDidCredentials: { + lexicon: 1, + id: 'com.atproto.identity.getRecommendedDidCredentials', + defs: { + main: { + type: 'query', + description: + 'Describe the credentials that should be included in the DID doc of an account that is migrating to this service.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + rotationKeys: { + description: + 'Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs.', + type: 'array', + items: { + type: 'string', + }, + }, + alsoKnownAs: { + type: 'array', + items: { + type: 'string', + }, + }, + verificationMethods: { + type: 'unknown', + }, + services: { + type: 'unknown', + }, + }, }, }, }, - blobView: { - type: 'object', - required: ['cid', 'mimeType', 'size', 'createdAt'], - properties: { - cid: { - type: 'string', - format: 'cid', - }, - mimeType: { - type: 'string', - }, - size: { - type: 'integer', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - details: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#imageDetails', - 'lex:com.atproto.admin.defs#videoDetails', - ], - }, - moderation: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#moderation', - }, - }, + }, + }, + ComAtprotoIdentityRequestPlcOperationSignature: { + lexicon: 1, + id: 'com.atproto.identity.requestPlcOperationSignature', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to in order to request a signed PLC operation. Requires Auth.', }, - imageDetails: { - type: 'object', - required: ['width', 'height'], - properties: { - width: { - type: 'integer', - }, - height: { - type: 'integer', + }, + }, + ComAtprotoIdentityResolveHandle: { + lexicon: 1, + id: 'com.atproto.identity.resolveHandle', + defs: { + main: { + type: 'query', + description: 'Resolves a handle (domain name) to a DID.', + parameters: { + type: 'params', + required: ['handle'], + properties: { + handle: { + type: 'string', + format: 'handle', + description: 'The handle to resolve.', + }, }, }, - }, - videoDetails: { - type: 'object', - required: ['width', 'height', 'length'], - properties: { - width: { - type: 'integer', - }, - height: { - type: 'integer', - }, - length: { - type: 'integer', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, }, }, }, - subjectReviewState: { - type: 'string', - knownValues: [ - 'lex:com.atproto.admin.defs#reviewOpen', - 'lex:com.atproto.admin.defs#reviewEscalated', - 'lex:com.atproto.admin.defs#reviewClosed', - ], - }, - reviewOpen: { - type: 'token', - description: - 'Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator', - }, - reviewEscalated: { - type: 'token', - description: - 'Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator', - }, - reviewClosed: { - type: 'token', + }, + }, + ComAtprotoIdentitySignPlcOperation: { + lexicon: 1, + id: 'com.atproto.identity.signPlcOperation', + defs: { + main: { + type: 'procedure', description: - 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', - }, - modEventTakedown: { - type: 'object', - description: 'Take down a subject permanently or temporarily', - properties: { - comment: { - type: 'string', - }, - durationInHours: { - type: 'integer', - description: - 'Indicates how long the takedown should be in effect before automatically expiring.', + "Signs a PLC operation to update some value(s) in the requesting DID's document.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + token: { + description: + 'A token received through com.atproto.identity.requestPlcOperationSignature', + type: 'string', + }, + rotationKeys: { + type: 'array', + items: { + type: 'string', + }, + }, + alsoKnownAs: { + type: 'array', + items: { + type: 'string', + }, + }, + verificationMethods: { + type: 'unknown', + }, + services: { + type: 'unknown', + }, + }, }, }, - }, - modEventReverseTakedown: { - type: 'object', - description: 'Revert take down action on a subject', - properties: { - comment: { - type: 'string', - description: 'Describe reasoning behind the reversal.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['operation'], + properties: { + operation: { + type: 'unknown', + description: 'A signed DID PLC operation.', + }, + }, }, }, }, - modEventResolveAppeal: { - type: 'object', - description: 'Resolve appeal on a subject', - properties: { - comment: { - type: 'string', - description: 'Describe resolution.', + }, + }, + ComAtprotoIdentitySubmitPlcOperation: { + lexicon: 1, + id: 'com.atproto.identity.submitPlcOperation', + defs: { + main: { + type: 'procedure', + description: + "Validates a PLC operation to ensure that it doesn't violate a service's constraints or get the identity into a bad state, then submits it to the PLC registry", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['operation'], + properties: { + operation: { + type: 'unknown', + }, + }, }, }, }, - modEventComment: { - type: 'object', - description: 'Add a comment to a subject', - required: ['comment'], - properties: { - comment: { - type: 'string', - }, - sticky: { - type: 'boolean', - description: 'Make the comment persistent on the subject', + }, + }, + ComAtprotoIdentityUpdateHandle: { + lexicon: 1, + id: 'com.atproto.identity.updateHandle', + defs: { + main: { + type: 'procedure', + description: + "Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['handle'], + properties: { + handle: { + type: 'string', + format: 'handle', + description: 'The new handle.', + }, + }, }, }, }, - modEventReport: { + }, + }, + ComAtprotoLabelDefs: { + lexicon: 1, + id: 'com.atproto.label.defs', + defs: { + label: { type: 'object', - description: 'Report a subject', - required: ['reportType'], + description: + 'Metadata tag on an atproto resource (eg, repo or record).', + required: ['src', 'uri', 'val', 'cts'], properties: { - comment: { - type: 'string', - }, - reportType: { - type: 'ref', - ref: 'lex:com.atproto.moderation.defs#reasonType', + ver: { + type: 'integer', + description: 'The AT Protocol version of the label object.', }, - }, - }, - modEventLabel: { - type: 'object', - description: 'Apply/Negate labels on a subject', - required: ['createLabelVals', 'negateLabelVals'], - properties: { - comment: { + src: { type: 'string', + format: 'did', + description: 'DID of the actor who created this label.', }, - createLabelVals: { - type: 'array', - items: { - type: 'string', - }, + uri: { + type: 'string', + format: 'uri', + description: + 'AT URI of the record, repository (account), or other resource that this label applies to.', }, - negateLabelVals: { - type: 'array', - items: { - type: 'string', - }, + cid: { + type: 'string', + format: 'cid', + description: + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", }, - }, - }, - modEventAcknowledge: { - type: 'object', - properties: { - comment: { + val: { type: 'string', + maxLength: 128, + description: + 'The short string name of the value or type of this label.', }, - }, - }, - modEventEscalate: { - type: 'object', - properties: { - comment: { + neg: { + type: 'boolean', + description: + 'If true, this is a negation label, overwriting a previous label.', + }, + cts: { type: 'string', + format: 'datetime', + description: 'Timestamp when this label was created.', }, - }, - }, - modEventMute: { - type: 'object', - description: 'Mute incoming reports on a subject', - required: ['durationInHours'], - properties: { - comment: { + exp: { type: 'string', + format: 'datetime', + description: + 'Timestamp at which this label expires (no longer applies).', }, - durationInHours: { - type: 'integer', - description: 'Indicates how long the subject should remain muted.', + sig: { + type: 'bytes', + description: 'Signature of dag-cbor encoded label.', }, }, }, - modEventUnmute: { + selfLabels: { type: 'object', - description: 'Unmute action on a subject', + description: + 'Metadata tags on an atproto record, published by the author within the record.', + required: ['values'], properties: { - comment: { - type: 'string', - description: 'Describe reasoning behind the reversal.', + values: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#selfLabel', + }, + maxLength: 10, }, }, }, - modEventEmail: { + selfLabel: { type: 'object', - description: 'Keep a log of outgoing email to a user', - required: ['subjectLine'], + description: + 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', + required: ['val'], properties: { - subjectLine: { - type: 'string', - description: 'The subject line of the email sent to the user.', - }, - comment: { + val: { type: 'string', - description: 'Additional comment about the outgoing comm.', + maxLength: 128, + description: + 'The short string name of the value or type of this label.', }, }, }, - communicationTemplateView: { + labelValueDefinition: { type: 'object', - required: [ - 'id', - 'name', - 'contentMarkdown', - 'disabled', - 'lastUpdatedBy', - 'createdAt', - 'updatedAt', - ], + description: + 'Declares a label value and its expected interpertations and behaviors.', + required: ['identifier', 'severity', 'blurs', 'locales'], properties: { - id: { + 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, }, - name: { + severity: { type: 'string', - description: 'Name of the template.', + 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'], }, - subject: { + blurs: { type: 'string', description: - 'Content of the template, can contain markdown and variable placeholders.', + "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'], }, - contentMarkdown: { + defaultSetting: { type: 'string', - description: 'Subject of the message, used in emails.', + description: 'The default setting for this label.', + knownValues: ['ignore', 'warn', 'hide'], + default: 'warn', }, - disabled: { + adultOnly: { type: 'boolean', + description: + 'Does the user need to have adult content enabled in order to configure this label?', }, - lastUpdatedBy: { - type: 'string', - format: 'did', - description: 'DID of the user who last updated the template.', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - updatedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, - }, - }, - ComAtprotoAdminDeleteAccount: { - lexicon: 1, - id: 'com.atproto.admin.deleteAccount', - defs: { - main: { - type: 'procedure', - description: 'Delete a user account as an administrator.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - }, + locales: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', }, }, }, }, - }, - }, - ComAtprotoAdminDeleteCommunicationTemplate: { - lexicon: 1, - id: 'com.atproto.admin.deleteCommunicationTemplate', - defs: { - main: { - type: 'procedure', - description: 'Delete a communication template.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['id'], - properties: { - id: { - type: 'string', - }, - }, + 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', + ], + }, }, }, - ComAtprotoAdminDisableAccountInvites: { + ComAtprotoLabelQueryLabels: { lexicon: 1, - id: 'com.atproto.admin.disableAccountInvites', + id: 'com.atproto.label.queryLabels', defs: { main: { - type: 'procedure', + type: 'query', description: - 'Disable an account from receiving new invite codes, but does not invalidate existing codes.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['account'], - properties: { - account: { + 'Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.', + parameters: { + type: 'params', + required: ['uriPatterns'], + properties: { + uriPatterns: { + type: 'array', + items: { type: 'string', - format: 'did', }, - note: { + description: + "List of AT URI patterns to match (boolean 'OR'). Each may be a prefix (ending with '*'; will match inclusive of the string leading to '*'), or a full URI.", + }, + sources: { + type: 'array', + items: { type: 'string', - description: 'Optional reason for disabled invites.', + format: 'did', }, + description: + 'Optional list of label sources (DIDs) to filter on.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 250, + default: 50, + }, + cursor: { + type: 'string', }, }, }, - }, - }, - }, - ComAtprotoAdminDisableInviteCodes: { - lexicon: 1, - id: 'com.atproto.admin.disableInviteCodes', - defs: { - main: { - type: 'procedure', - description: - 'Disable some set of codes and/or all codes associated with a set of users.', - input: { + output: { encoding: 'application/json', schema: { type: 'object', + required: ['labels'], properties: { - codes: { - type: 'array', - items: { - type: 'string', - }, + cursor: { + type: 'string', }, - accounts: { + labels: { type: 'array', items: { - type: 'string', + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', }, }, }, @@ -1047,156 +982,142 @@ export const schemaDict = { }, }, }, - ComAtprotoAdminEmitModerationEvent: { + ComAtprotoLabelSubscribeLabels: { lexicon: 1, - id: 'com.atproto.admin.emitModerationEvent', + id: 'com.atproto.label.subscribeLabels', defs: { main: { - type: 'procedure', - description: 'Take a moderation action on an actor.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['event', 'subject', 'createdBy'], - properties: { - event: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#modEventTakedown', - 'lex:com.atproto.admin.defs#modEventAcknowledge', - 'lex:com.atproto.admin.defs#modEventEscalate', - 'lex:com.atproto.admin.defs#modEventComment', - 'lex:com.atproto.admin.defs#modEventLabel', - 'lex:com.atproto.admin.defs#modEventReport', - 'lex:com.atproto.admin.defs#modEventMute', - 'lex:com.atproto.admin.defs#modEventReverseTakedown', - 'lex:com.atproto.admin.defs#modEventUnmute', - 'lex:com.atproto.admin.defs#modEventEmail', - ], - }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], - }, - subjectBlobCids: { - type: 'array', - items: { - type: 'string', - format: 'cid', - }, - }, - createdBy: { - type: 'string', - format: 'did', - }, + type: 'subscription', + description: + 'Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.', + parameters: { + type: 'params', + properties: { + cursor: { + type: 'integer', + description: 'The last known event seq number to backfill from.', }, }, }, - output: { - encoding: 'application/json', + message: { schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#modEventView', + type: 'union', + refs: [ + 'lex:com.atproto.label.subscribeLabels#labels', + 'lex:com.atproto.label.subscribeLabels#info', + ], }, }, errors: [ { - name: 'SubjectHasAction', + name: 'FutureCursor', }, ], }, + labels: { + type: 'object', + required: ['seq', 'labels'], + properties: { + seq: { + type: 'integer', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + info: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + knownValues: ['OutdatedCursor'], + }, + message: { + type: 'string', + }, + }, + }, }, }, - ComAtprotoAdminEnableAccountInvites: { + ComAtprotoModerationCreateReport: { lexicon: 1, - id: 'com.atproto.admin.enableAccountInvites', + id: 'com.atproto.moderation.createReport', defs: { main: { type: 'procedure', - description: "Re-enable an account's ability to receive invite codes.", + description: + 'Submit a moderation report regarding an atproto account or record. Implemented by moderation services (with PDS proxying), and requires auth.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['account'], + required: ['reasonType', 'subject'], properties: { - account: { - type: 'string', - format: 'did', + reasonType: { + type: 'ref', + description: + 'Indicates the broad category of violation the report is for.', + ref: 'lex:com.atproto.moderation.defs#reasonType', }, - note: { + reason: { type: 'string', - description: 'Optional reason for enabled invites.', + description: + 'Additional context about the content and violation.', + }, + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + ], }, - }, - }, - }, - }, - }, - }, - ComAtprotoAdminGetAccountInfo: { - lexicon: 1, - id: 'com.atproto.admin.getAccountInfo', - defs: { - main: { - type: 'query', - description: 'Get details about an account.', - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', }, }, }, output: { encoding: 'application/json', schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#accountView', - }, - }, - }, - }, - }, - ComAtprotoAdminGetAccountInfos: { - lexicon: 1, - id: 'com.atproto.admin.getAccountInfos', - defs: { - main: { - type: 'query', - description: 'Get details about some accounts.', - parameters: { - type: 'params', - required: ['dids'], - properties: { - dids: { - type: 'array', - items: { + type: 'object', + required: [ + 'id', + 'reasonType', + 'subject', + 'reportedBy', + 'createdAt', + ], + properties: { + id: { + type: 'integer', + }, + reasonType: { + type: 'ref', + ref: 'lex:com.atproto.moderation.defs#reasonType', + }, + reason: { + type: 'string', + maxGraphemes: 2000, + maxLength: 20000, + }, + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + ], + }, + reportedBy: { type: 'string', format: 'did', }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['infos'], - properties: { - infos: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#accountView', - }, + createdAt: { + type: 'string', + format: 'datetime', }, }, }, @@ -1204,270 +1125,303 @@ export const schemaDict = { }, }, }, - ComAtprotoAdminGetInviteCodes: { + ComAtprotoModerationDefs: { lexicon: 1, - id: 'com.atproto.admin.getInviteCodes', + id: 'com.atproto.moderation.defs', + defs: { + reasonType: { + type: 'string', + knownValues: [ + 'com.atproto.moderation.defs#reasonSpam', + 'com.atproto.moderation.defs#reasonViolation', + 'com.atproto.moderation.defs#reasonMisleading', + 'com.atproto.moderation.defs#reasonSexual', + 'com.atproto.moderation.defs#reasonRude', + 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', + ], + }, + reasonSpam: { + type: 'token', + description: 'Spam: frequent unwanted promotion, replies, mentions', + }, + reasonViolation: { + type: 'token', + description: 'Direct violation of server rules, laws, terms of service', + }, + reasonMisleading: { + type: 'token', + description: 'Misleading identity, affiliation, or content', + }, + reasonSexual: { + type: 'token', + description: 'Unwanted or mislabeled sexual content', + }, + reasonRude: { + type: 'token', + description: + 'Rude, harassing, explicit, or otherwise unwelcoming behavior', + }, + reasonOther: { + type: 'token', + description: 'Other: reports not falling under another report category', + }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, + }, + }, + ComAtprotoRepoApplyWrites: { + lexicon: 1, + id: 'com.atproto.repo.applyWrites', defs: { main: { - type: 'query', - description: 'Get an admin view of invite codes.', - parameters: { - type: 'params', - properties: { - sort: { - type: 'string', - knownValues: ['recent', 'usage'], - default: 'recent', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 500, - default: 100, - }, - cursor: { - type: 'string', - }, - }, - }, - output: { + type: 'procedure', + description: + 'Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.', + input: { encoding: 'application/json', schema: { type: 'object', - required: ['codes'], + required: ['repo', 'writes'], properties: { - cursor: { + repo: { type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', }, - codes: { + validate: { + type: 'boolean', + default: true, + description: + "Can be set to 'false' to skip Lexicon schema validation of record data, for all operations.", + }, + writes: { type: 'array', items: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', + type: 'union', + refs: [ + 'lex:com.atproto.repo.applyWrites#create', + 'lex:com.atproto.repo.applyWrites#update', + 'lex:com.atproto.repo.applyWrites#delete', + ], + closed: true, }, }, + swapCommit: { + type: 'string', + description: + 'If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.', + format: 'cid', + }, }, }, }, + errors: [ + { + name: 'InvalidSwap', + description: + "Indicates that the 'swapCommit' parameter did not match current commit.", + }, + ], }, - }, - }, - ComAtprotoAdminGetModerationEvent: { - lexicon: 1, - id: 'com.atproto.admin.getModerationEvent', - defs: { - main: { - type: 'query', - description: 'Get details about a moderation event.', - parameters: { - type: 'params', - required: ['id'], - properties: { - id: { - type: 'integer', - }, + create: { + type: 'object', + description: 'Operation which creates a new record.', + required: ['collection', 'value'], + properties: { + collection: { + type: 'string', + format: 'nsid', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#modEventViewDetail', + rkey: { + type: 'string', + maxLength: 15, + }, + value: { + type: 'unknown', }, }, }, - }, - }, - ComAtprotoAdminGetRecord: { - lexicon: 1, - id: 'com.atproto.admin.getRecord', - defs: { - main: { - type: 'query', - description: 'Get details about a record.', - parameters: { - type: 'params', - required: ['uri'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, + update: { + type: 'object', + description: 'Operation which updates an existing record.', + required: ['collection', 'rkey', 'value'], + properties: { + collection: { + type: 'string', + format: 'nsid', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#recordViewDetail', + rkey: { + type: 'string', + }, + value: { + type: 'unknown', }, }, - errors: [ - { - name: 'RecordNotFound', + }, + delete: { + type: 'object', + description: 'Operation which deletes an existing record.', + required: ['collection', 'rkey'], + properties: { + collection: { + type: 'string', + format: 'nsid', }, - ], + rkey: { + type: 'string', + }, + }, }, }, }, - ComAtprotoAdminGetRepo: { + ComAtprotoRepoCreateRecord: { lexicon: 1, - id: 'com.atproto.admin.getRepo', + id: 'com.atproto.repo.createRecord', defs: { main: { - type: 'query', - description: 'Get details about a repository.', - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', + type: 'procedure', + description: + 'Create a single new repository record. Requires auth, implemented by PDS.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'collection', 'record'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', + }, + rkey: { + type: 'string', + description: 'The Record Key.', + maxLength: 15, + }, + validate: { + type: 'boolean', + default: true, + description: + "Can be set to 'false' to skip Lexicon schema validation of record data.", + }, + record: { + type: 'unknown', + description: 'The record itself. Must contain a $type field.', + }, + swapCommit: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous commit by CID.', + }, }, }, }, output: { encoding: 'application/json', schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#repoViewDetail', + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + }, }, }, errors: [ { - name: 'RepoNotFound', + name: 'InvalidSwap', + description: + "Indicates that 'swapCommit' didn't match current repo commit.", }, ], }, }, }, - ComAtprotoAdminGetSubjectStatus: { + ComAtprotoRepoDeleteRecord: { lexicon: 1, - id: 'com.atproto.admin.getSubjectStatus', + id: 'com.atproto.repo.deleteRecord', defs: { main: { - type: 'query', + type: 'procedure', description: - 'Get the service-specific admin status of a subject (account, record, or blob).', - parameters: { - type: 'params', - properties: { - did: { - type: 'string', - format: 'did', - }, - uri: { - type: 'string', - format: 'at-uri', - }, - blob: { - type: 'string', - format: 'cid', - }, - }, - }, - output: { + "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", + input: { encoding: 'application/json', schema: { type: 'object', - required: ['subject'], + required: ['repo', 'collection', 'rkey'], properties: { - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - 'lex:com.atproto.admin.defs#repoBlobRef', - ], + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', }, - takedown: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#statusAttr', + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', }, - }, - }, - }, - }, - }, - }, - ComAtprotoAdminListCommunicationTemplates: { - lexicon: 1, - id: 'com.atproto.admin.listCommunicationTemplates', - defs: { - main: { - type: 'query', - description: 'Get list of all communication templates.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['communicationTemplates'], - properties: { - communicationTemplates: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#communicationTemplateView', - }, + rkey: { + type: 'string', + description: 'The Record Key.', + }, + swapRecord: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous record by CID.', + }, + swapCommit: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous commit by CID.', }, }, }, }, + errors: [ + { + name: 'InvalidSwap', + }, + ], }, }, }, - ComAtprotoAdminQueryModerationEvents: { + ComAtprotoRepoDescribeRepo: { lexicon: 1, - id: 'com.atproto.admin.queryModerationEvents', + id: 'com.atproto.repo.describeRepo', defs: { main: { type: 'query', - description: 'List moderation events related to a subject.', + description: + 'Get information about an account and repository, including the list of collections. Does not require auth.', parameters: { type: 'params', + required: ['repo'], properties: { - types: { - type: 'array', - items: { - type: 'string', - }, - description: - 'The types of events (fully qualified string in the format of com.atproto.admin#modEvent) to filter by. If not specified, all events are returned.', - }, - createdBy: { - type: 'string', - format: 'did', - }, - sortDirection: { - type: 'string', - default: 'desc', - enum: ['asc', 'desc'], - description: - 'Sort direction for the events. Defaults to descending order of created at timestamp.', - }, - subject: { - type: 'string', - format: 'uri', - }, - includeAllUserRecords: { - type: 'boolean', - default: false, - description: - 'If true, events on all record types (posts, lists, profile etc.) owned by the did are returned', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { + repo: { type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repo.', }, }, }, @@ -1475,107 +1429,133 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['events'], + required: [ + 'handle', + 'did', + 'didDoc', + 'collections', + 'handleIsCorrect', + ], properties: { - cursor: { + handle: { type: 'string', + format: 'handle', }, - events: { + did: { + type: 'string', + format: 'did', + }, + didDoc: { + type: 'unknown', + description: 'The complete DID document for this account.', + }, + collections: { type: 'array', + description: + 'List of all the collections (NSIDs) for which this repo contains at least one record.', items: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#modEventView', + type: 'string', + format: 'nsid', }, }, + handleIsCorrect: { + type: 'boolean', + description: + 'Indicates if handle is currently valid (resolves bi-directionally)', + }, }, }, }, }, }, }, - ComAtprotoAdminQueryModerationStatuses: { + ComAtprotoRepoGetRecord: { lexicon: 1, - id: 'com.atproto.admin.queryModerationStatuses', + id: 'com.atproto.repo.getRecord', defs: { main: { type: 'query', - description: 'View moderation statuses of subjects (record or repo).', + description: + 'Get a single record from a repository. Does not require auth.', parameters: { type: 'params', + required: ['repo', 'collection', 'rkey'], properties: { - subject: { - type: 'string', - format: 'uri', - }, - comment: { - type: 'string', - description: 'Search subjects by keyword from comments', - }, - reportedAfter: { - type: 'string', - format: 'datetime', - description: 'Search subjects reported after a given timestamp', - }, - reportedBefore: { - type: 'string', - format: 'datetime', - description: 'Search subjects reported before a given timestamp', - }, - reviewedAfter: { + repo: { type: 'string', - format: 'datetime', - description: 'Search subjects reviewed after a given timestamp', + format: 'at-identifier', + description: 'The handle or DID of the repo.', }, - reviewedBefore: { + collection: { type: 'string', - format: 'datetime', - description: 'Search subjects reviewed before a given timestamp', - }, - includeMuted: { - type: 'boolean', - description: - "By default, we don't include muted subjects in the results. Set this to true to include them.", + format: 'nsid', + description: 'The NSID of the record collection.', }, - reviewState: { + rkey: { type: 'string', - description: 'Specify when fetching subjects in a certain state', - }, - ignoreSubjects: { - type: 'array', - items: { - type: 'string', - format: 'uri', - }, + description: 'The Record Key.', }, - lastReviewedBy: { + cid: { type: 'string', - format: 'did', + format: 'cid', description: - 'Get all subject statuses that were reviewed by a specific moderator', - }, - sortField: { - type: 'string', - default: 'lastReportedAt', - enum: ['lastReviewedAt', 'lastReportedAt'], - }, - sortDirection: { - type: 'string', - default: 'desc', - enum: ['asc', 'desc'], - }, - takendown: { - type: 'boolean', - description: 'Get subjects that were taken down', + 'The CID of the version of the record. If not specified, then return the most recent version.', }, - appealed: { - type: 'boolean', - description: 'Get subjects in unresolved appealed status', + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'value'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + value: { + type: 'unknown', + }, }, + }, + }, + }, + }, + }, + ComAtprotoRepoImportRepo: { + lexicon: 1, + id: 'com.atproto.repo.importRepo', + defs: { + main: { + type: 'procedure', + description: + 'Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.', + input: { + encoding: 'application/vnd.ipld.car', + }, + }, + }, + }, + ComAtprotoRepoListMissingBlobs: { + lexicon: 1, + id: 'com.atproto.repo.listMissingBlobs', + defs: { + main: { + type: 'query', + description: + 'Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.', + parameters: { + type: 'params', + properties: { limit: { type: 'integer', minimum: 1, - maximum: 100, - default: 50, + maximum: 1000, + default: 500, }, cursor: { type: 'string', @@ -1586,105 +1566,177 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['subjectStatuses'], + required: ['blobs'], properties: { cursor: { type: 'string', }, - subjectStatuses: { + blobs: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.admin.defs#subjectStatusView', + ref: 'lex:com.atproto.repo.listMissingBlobs#recordBlob', }, }, }, }, }, }, + recordBlob: { + type: 'object', + required: ['cid', 'recordUri'], + properties: { + cid: { + type: 'string', + format: 'cid', + }, + recordUri: { + type: 'string', + format: 'at-uri', + }, + }, + }, }, }, - ComAtprotoAdminSearchRepos: { + ComAtprotoRepoListRecords: { lexicon: 1, - id: 'com.atproto.admin.searchRepos', + id: 'com.atproto.repo.listRecords', defs: { main: { type: 'query', - description: 'Find repositories based on a search term.', + description: + 'List a range of records in a repository, matching a specific collection. Does not require auth.', parameters: { type: 'params', + required: ['repo', 'collection'], properties: { - term: { + repo: { type: 'string', - description: "DEPRECATED: use 'q' instead", + format: 'at-identifier', + description: 'The handle or DID of the repo.', }, - q: { + collection: { type: 'string', + format: 'nsid', + description: 'The NSID of the record type.', }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 50, + description: 'The number of records to return.', }, cursor: { type: 'string', }, + rkeyStart: { + type: 'string', + description: + 'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)', + }, + rkeyEnd: { + type: 'string', + description: + 'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)', + }, + reverse: { + type: 'boolean', + description: 'Flag to reverse the order of the returned records.', + }, }, }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['repos'], + required: ['records'], properties: { cursor: { type: 'string', }, - repos: { + records: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.admin.defs#repoView', + ref: 'lex:com.atproto.repo.listRecords#record', }, }, }, }, }, }, + record: { + type: 'object', + required: ['uri', 'cid', 'value'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + value: { + type: 'unknown', + }, + }, + }, }, }, - ComAtprotoAdminSendEmail: { + ComAtprotoRepoPutRecord: { lexicon: 1, - id: 'com.atproto.admin.sendEmail', + id: 'com.atproto.repo.putRecord', defs: { main: { type: 'procedure', - description: "Send email to a user's account email address.", + description: + 'Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['recipientDid', 'content', 'senderDid'], + required: ['repo', 'collection', 'rkey', 'record'], + nullable: ['swapRecord'], properties: { - recipientDid: { + repo: { type: 'string', - format: 'did', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', }, - content: { + collection: { type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', }, - subject: { + rkey: { type: 'string', + description: 'The Record Key.', + maxLength: 15, }, - senderDid: { + validate: { + type: 'boolean', + default: true, + description: + "Can be set to 'false' to skip Lexicon schema validation of record data.", + }, + record: { + type: 'unknown', + description: 'The record to write.', + }, + swapRecord: { type: 'string', - format: 'did', + format: 'cid', + description: + 'Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation', }, - comment: { + swapCommit: { type: 'string', + format: 'cid', description: - "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + 'Compare and swap with the previous commit by CID.', }, }, }, @@ -1693,148 +1745,233 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['sent'], + required: ['uri', 'cid'], properties: { - sent: { - type: 'boolean', + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', }, }, }, }, + errors: [ + { + name: 'InvalidSwap', + }, + ], + }, + }, + }, + ComAtprotoRepoStrongRef: { + lexicon: 1, + id: 'com.atproto.repo.strongRef', + description: 'A URI with a content-hash fingerprint.', + defs: { + main: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + }, }, }, }, - ComAtprotoAdminUpdateAccountEmail: { + ComAtprotoRepoUploadBlob: { lexicon: 1, - id: 'com.atproto.admin.updateAccountEmail', + id: 'com.atproto.repo.uploadBlob', defs: { main: { type: 'procedure', - description: "Administrative action to update an account's email.", + description: + 'Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.', input: { + encoding: '*/*', + }, + output: { encoding: 'application/json', schema: { type: 'object', - required: ['account', 'email'], + required: ['blob'], properties: { - account: { + blob: { + type: 'blob', + }, + }, + }, + }, + }, + }, + }, + ComAtprotoServerActivateAccount: { + lexicon: 1, + id: 'com.atproto.server.activateAccount', + defs: { + main: { + type: 'procedure', + description: + "Activates a currently deactivated account. Used to finalize account migration after the account's repo is imported and identity is setup.", + }, + }, + }, + ComAtprotoServerCheckAccountStatus: { + lexicon: 1, + id: 'com.atproto.server.checkAccountStatus', + defs: { + main: { + type: 'query', + description: + 'Returns the status of an account, especially as pertaining to import or recovery. Can be called many times over the course of an account migration. Requires auth and can only be called pertaining to oneself.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: [ + 'activated', + 'validDid', + 'repoCommit', + 'repoRev', + 'repoBlocks', + 'indexedRecords', + 'privateStateValues', + 'expectedBlobs', + 'importedBlobs', + ], + properties: { + activated: { + type: 'boolean', + }, + validDid: { + type: 'boolean', + }, + repoCommit: { type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', + format: 'cid', }, - email: { + repoRev: { type: 'string', }, + repoBlocks: { + type: 'integer', + }, + indexedRecords: { + type: 'integer', + }, + privateStateValues: { + type: 'integer', + }, + expectedBlobs: { + type: 'integer', + }, + importedBlobs: { + type: 'integer', + }, }, }, }, }, }, }, - ComAtprotoAdminUpdateAccountHandle: { + ComAtprotoServerConfirmEmail: { lexicon: 1, - id: 'com.atproto.admin.updateAccountHandle', + id: 'com.atproto.server.confirmEmail', defs: { main: { type: 'procedure', - description: "Administrative action to update an account's handle.", + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['did', 'handle'], + required: ['email', 'token'], properties: { - did: { + email: { type: 'string', - format: 'did', }, - handle: { + token: { type: 'string', - format: 'handle', }, }, }, }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], }, }, }, - ComAtprotoAdminUpdateCommunicationTemplate: { + ComAtprotoServerCreateAccount: { lexicon: 1, - id: 'com.atproto.admin.updateCommunicationTemplate', + id: 'com.atproto.server.createAccount', defs: { main: { type: 'procedure', - description: - 'Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.', + description: 'Create an account. Implemented by PDS.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['id'], + required: ['handle'], properties: { - id: { + email: { type: 'string', - description: 'ID of the template to be updated.', }, - name: { + handle: { type: 'string', - description: 'Name of the template.', + format: 'handle', + description: 'Requested handle for the account.', }, - contentMarkdown: { + did: { type: 'string', + format: 'did', description: - 'Content of the template, markdown supported, can contain variable placeholders.', + 'Pre-existing atproto DID, being imported to a new account.', }, - subject: { + inviteCode: { type: 'string', - description: 'Subject of the message, used in emails.', }, - updatedBy: { + verificationCode: { type: 'string', - format: 'did', - description: 'DID of the user who is updating the template.', }, - disabled: { - type: 'boolean', + verificationPhone: { + type: 'string', }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#communicationTemplateView', - }, - }, - }, - }, - }, - ComAtprotoAdminUpdateSubjectStatus: { - lexicon: 1, - id: 'com.atproto.admin.updateSubjectStatus', - defs: { - main: { - type: 'procedure', - description: - 'Update the service-specific admin status of a subject (account, record, or blob).', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['subject'], - properties: { - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - 'lex:com.atproto.admin.defs#repoBlobRef', - ], + password: { + type: 'string', + description: + 'Initial account password. May need to meet instance-specific password strength requirements.', }, - takedown: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#statusAttr', + recoveryKey: { + type: 'string', + description: + 'DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.', + }, + plcOp: { + type: 'unknown', + description: + 'A signed DID PLC operation to be submitted as part of importing an existing account to this instance. NOTE: this optional field may be updated when full account migration is implemented.', }, }, }, @@ -1843,196 +1980,130 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['subject'], + description: + 'Account login session returned on successful account creation.', + required: ['accessJwt', 'refreshJwt', 'handle', 'did'], properties: { - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - 'lex:com.atproto.admin.defs#repoBlobRef', - ], + accessJwt: { + type: 'string', }, - takedown: { - type: 'ref', - ref: 'lex:com.atproto.admin.defs#statusAttr', + refreshJwt: { + type: 'string', + }, + handle: { + type: 'string', + format: 'handle', }, - }, - }, - }, - }, - }, - }, - ComAtprotoIdentityResolveHandle: { - lexicon: 1, - id: 'com.atproto.identity.resolveHandle', - defs: { - main: { - type: 'query', - description: 'Provides the DID of a repo.', - parameters: { - type: 'params', - required: ['handle'], - properties: { - handle: { - type: 'string', - format: 'handle', - description: 'The handle to resolve.', - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['did'], - properties: { did: { type: 'string', format: 'did', + description: 'The DID of the new account.', + }, + didDoc: { + type: 'unknown', + description: 'Complete DID document.', }, }, }, }, + errors: [ + { + name: 'InvalidHandle', + }, + { + name: 'InvalidPassword', + }, + { + name: 'InvalidInviteCode', + }, + { + name: 'HandleNotAvailable', + }, + { + name: 'UnsupportedDomain', + }, + { + name: 'UnresolvableDid', + }, + { + name: 'IncompatibleDidDoc', + }, + ], }, }, }, - ComAtprotoIdentityUpdateHandle: { + ComAtprotoServerCreateAppPassword: { lexicon: 1, - id: 'com.atproto.identity.updateHandle', + id: 'com.atproto.server.createAppPassword', defs: { main: { type: 'procedure', - description: 'Updates the handle of the account.', + description: 'Create an App Password.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['handle'], + required: ['name'], properties: { - handle: { + name: { type: 'string', - format: 'handle', + description: + 'A short name for the App Password, to help distinguish them.', }, }, }, }, - }, - }, - }, - ComAtprotoLabelDefs: { - lexicon: 1, - id: 'com.atproto.label.defs', - defs: { - label: { - type: 'object', - description: - 'Metadata tag on an atproto resource (eg, repo or record).', - required: ['src', 'uri', 'val', 'cts'], - properties: { - src: { - type: 'string', - format: 'did', - description: 'DID of the actor who created this label.', - }, - uri: { - type: 'string', - format: 'uri', - description: - 'AT URI of the record, repository (account), or other resource that this label applies to.', - }, - cid: { - type: 'string', - format: 'cid', - description: - "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", - }, - val: { - type: 'string', - maxLength: 128, - description: - 'The short string name of the value or type of this label.', - }, - neg: { - type: 'boolean', - description: - 'If true, this is a negation label, overwriting a previous label.', - }, - cts: { - type: 'string', - format: 'datetime', - description: 'Timestamp when this label was created.', + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.server.createAppPassword#appPassword', }, }, - }, - selfLabels: { - type: 'object', - description: - 'Metadata tags on an atproto record, published by the author within the record.', - required: ['values'], - properties: { - values: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#selfLabel', - }, - maxLength: 10, + errors: [ + { + name: 'AccountTakedown', }, - }, + ], }, - selfLabel: { + appPassword: { type: 'object', - description: - 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', - required: ['val'], + required: ['name', 'password', 'createdAt'], properties: { - val: { + name: { type: 'string', - maxLength: 128, - description: - 'The short string name of the value or type of this label.', + }, + password: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', }, }, }, }, }, - ComAtprotoLabelQueryLabels: { + ComAtprotoServerCreateInviteCode: { lexicon: 1, - id: 'com.atproto.label.queryLabels', + id: 'com.atproto.server.createInviteCode', defs: { main: { - type: 'query', - description: 'Find labels relevant to the provided URI patterns.', - parameters: { - type: 'params', - required: ['uriPatterns'], - properties: { - uriPatterns: { - type: 'array', - items: { - type: 'string', + type: 'procedure', + description: 'Create an invite code.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['useCount'], + properties: { + useCount: { + type: 'integer', }, - description: - "List of AT URI patterns to match (boolean 'OR'). Each may be a prefix (ending with '*'; will match inclusive of the string leading to '*'), or a full URI.", - }, - sources: { - type: 'array', - items: { + forAccount: { type: 'string', format: 'did', }, - description: - 'Optional list of label sources (DIDs) to filter on.', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 250, - default: 50, - }, - cursor: { - type: 'string', }, }, }, @@ -2040,112 +2111,101 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['labels'], + required: ['code'], properties: { - cursor: { + code: { type: 'string', }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, - }, }, }, }, }, }, }, - ComAtprotoLabelSubscribeLabels: { + ComAtprotoServerCreateInviteCodes: { lexicon: 1, - id: 'com.atproto.label.subscribeLabels', + id: 'com.atproto.server.createInviteCodes', defs: { main: { - type: 'subscription', - description: 'Subscribe to label updates.', - parameters: { - type: 'params', - properties: { - cursor: { - type: 'integer', - description: 'The last known event to backfill from.', + type: 'procedure', + description: 'Create invite codes.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codeCount', 'useCount'], + properties: { + codeCount: { + type: 'integer', + default: 1, + }, + useCount: { + type: 'integer', + }, + forAccounts: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, }, }, }, - message: { + output: { + encoding: 'application/json', schema: { - type: 'union', - refs: [ - 'lex:com.atproto.label.subscribeLabels#labels', - 'lex:com.atproto.label.subscribeLabels#info', - ], + type: 'object', + required: ['codes'], + properties: { + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.createInviteCodes#accountCodes', + }, + }, + }, }, }, - errors: [ - { - name: 'FutureCursor', - }, - ], }, - labels: { + accountCodes: { type: 'object', - required: ['seq', 'labels'], + required: ['account', 'codes'], properties: { - seq: { - type: 'integer', + account: { + type: 'string', }, - labels: { + codes: { type: 'array', items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', + type: 'string', }, }, }, }, - info: { - type: 'object', - required: ['name'], - properties: { - name: { - type: 'string', - knownValues: ['OutdatedCursor'], - }, - message: { - type: 'string', - }, - }, - }, }, }, - ComAtprotoModerationCreateReport: { + ComAtprotoServerCreateSession: { lexicon: 1, - id: 'com.atproto.moderation.createReport', + id: 'com.atproto.server.createSession', defs: { main: { type: 'procedure', - description: 'Report a repo or a record.', + description: 'Create an authentication session.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['reasonType', 'subject'], + required: ['identifier', 'password'], properties: { - reasonType: { - type: 'ref', - ref: 'lex:com.atproto.moderation.defs#reasonType', - }, - reason: { + identifier: { type: 'string', + description: + 'Handle or other identifier supported by the server for the authenticating user.', }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], + password: { + type: 'string', }, }, }, @@ -2154,331 +2214,310 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: [ - 'id', - 'reasonType', - 'subject', - 'reportedBy', - 'createdAt', - ], + required: ['accessJwt', 'refreshJwt', 'handle', 'did'], properties: { - id: { - type: 'integer', - }, - reasonType: { - type: 'ref', - ref: 'lex:com.atproto.moderation.defs#reasonType', + accessJwt: { + type: 'string', }, - reason: { + refreshJwt: { type: 'string', - maxGraphemes: 2000, - maxLength: 20000, }, - subject: { - type: 'union', - refs: [ - 'lex:com.atproto.admin.defs#repoRef', - 'lex:com.atproto.repo.strongRef', - ], + handle: { + type: 'string', + format: 'handle', }, - reportedBy: { + did: { type: 'string', format: 'did', }, - createdAt: { + didDoc: { + type: 'unknown', + }, + email: { type: 'string', - format: 'datetime', }, - }, - }, - }, - }, - }, - }, - ComAtprotoModerationDefs: { - lexicon: 1, - id: 'com.atproto.moderation.defs', - defs: { - reasonType: { - type: 'string', - knownValues: [ - 'com.atproto.moderation.defs#reasonSpam', - 'com.atproto.moderation.defs#reasonViolation', - 'com.atproto.moderation.defs#reasonMisleading', - 'com.atproto.moderation.defs#reasonSexual', - 'com.atproto.moderation.defs#reasonRude', - 'com.atproto.moderation.defs#reasonOther', - 'com.atproto.moderation.defs#reasonAppeal', - ], - }, - reasonSpam: { - type: 'token', - description: 'Spam: frequent unwanted promotion, replies, mentions', - }, - reasonViolation: { - type: 'token', - description: 'Direct violation of server rules, laws, terms of service', - }, - reasonMisleading: { - type: 'token', - description: 'Misleading identity, affiliation, or content', - }, - reasonSexual: { - type: 'token', - description: 'Unwanted or mislabeled sexual content', - }, - reasonRude: { - type: 'token', - description: - 'Rude, harassing, explicit, or otherwise unwelcoming behavior', - }, - reasonOther: { - type: 'token', - description: 'Other: reports not falling under another report category', - }, - reasonAppeal: { - type: 'token', - description: 'Appeal: appeal a previously taken moderation action', + emailConfirmed: { + type: 'boolean', + }, + }, + }, + }, + errors: [ + { + name: 'AccountTakedown', + }, + ], }, }, }, - ComAtprotoRepoApplyWrites: { + ComAtprotoServerDeactivateAccount: { lexicon: 1, - id: 'com.atproto.repo.applyWrites', + id: 'com.atproto.server.deactivateAccount', defs: { main: { type: 'procedure', description: - 'Apply a batch transaction of creates, updates, and deletes.', + 'Deactivates a currently active account. Stops serving of repo, and future writes to repo until reactivated. Used to finalize account migration with the old host after the account has been activated on the new host.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['repo', 'writes'], properties: { - repo: { - type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', - }, - validate: { - type: 'boolean', - default: true, - description: 'Flag for validating the records.', - }, - writes: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:com.atproto.repo.applyWrites#create', - 'lex:com.atproto.repo.applyWrites#update', - 'lex:com.atproto.repo.applyWrites#delete', - ], - closed: true, - }, - }, - swapCommit: { + deleteAfter: { type: 'string', - format: 'cid', + format: 'datetime', + description: + 'A recommendation to server as to how long they should hold onto the deactivated account before deleting.', }, }, }, }, - errors: [ - { - name: 'InvalidSwap', - }, - ], }, - create: { + }, + }, + ComAtprotoServerDefs: { + lexicon: 1, + id: 'com.atproto.server.defs', + defs: { + inviteCode: { type: 'object', - description: 'Create a new record.', - required: ['collection', 'value'], + required: [ + 'code', + 'available', + 'disabled', + 'forAccount', + 'createdBy', + 'createdAt', + 'uses', + ], properties: { - collection: { + code: { type: 'string', - format: 'nsid', }, - rkey: { - type: 'string', - maxLength: 15, + available: { + type: 'integer', }, - value: { - type: 'unknown', + disabled: { + type: 'boolean', }, - }, - }, - update: { - type: 'object', - description: 'Update an existing record.', - required: ['collection', 'rkey', 'value'], - properties: { - collection: { + forAccount: { type: 'string', - format: 'nsid', }, - rkey: { + createdBy: { type: 'string', }, - value: { - type: 'unknown', + createdAt: { + type: 'string', + format: 'datetime', + }, + uses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCodeUse', + }, }, }, }, - delete: { + inviteCodeUse: { type: 'object', - description: 'Delete an existing record.', - required: ['collection', 'rkey'], + required: ['usedBy', 'usedAt'], properties: { - collection: { + usedBy: { type: 'string', - format: 'nsid', + format: 'did', }, - rkey: { + usedAt: { type: 'string', + format: 'datetime', }, }, }, }, }, - ComAtprotoRepoCreateRecord: { + ComAtprotoServerDeleteAccount: { lexicon: 1, - id: 'com.atproto.repo.createRecord', + id: 'com.atproto.server.deleteAccount', defs: { main: { type: 'procedure', - description: 'Create a new record.', + description: + "Delete an actor's account with a token and password. Can only be called after requesting a deletion token. Requires auth.", input: { encoding: 'application/json', schema: { type: 'object', - required: ['repo', 'collection', 'record'], + required: ['did', 'password', 'token'], properties: { - repo: { - type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', - }, - collection: { - type: 'string', - format: 'nsid', - description: 'The NSID of the record collection.', - }, - rkey: { - type: 'string', - description: 'The key of the record.', - maxLength: 15, - }, - validate: { - type: 'boolean', - default: true, - description: 'Flag for validating the record.', - }, - record: { - type: 'unknown', - description: 'The record to create.', - }, - swapCommit: { + did: { type: 'string', - format: 'cid', - description: - 'Compare and swap with the previous commit by CID.', + format: 'did', }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['uri', 'cid'], - properties: { - uri: { + password: { type: 'string', - format: 'at-uri', }, - cid: { + token: { type: 'string', - format: 'cid', }, }, }, }, errors: [ { - name: 'InvalidSwap', + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', }, ], }, }, }, - ComAtprotoRepoDeleteRecord: { + ComAtprotoServerDeleteSession: { lexicon: 1, - id: 'com.atproto.repo.deleteRecord', + id: 'com.atproto.server.deleteSession', defs: { main: { type: 'procedure', - description: "Delete a record, or ensure it doesn't exist.", - input: { + description: 'Delete the current session. Requires auth.', + }, + }, + }, + ComAtprotoServerDescribeServer: { + lexicon: 1, + id: 'com.atproto.server.describeServer', + defs: { + main: { + type: 'query', + description: + "Describes the server's account creation requirements and capabilities. Implemented by PDS.", + output: { encoding: 'application/json', schema: { type: 'object', - required: ['repo', 'collection', 'rkey'], + required: ['did', 'availableUserDomains'], properties: { - repo: { - type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', - }, - collection: { - type: 'string', - format: 'nsid', - description: 'The NSID of the record collection.', + inviteCodeRequired: { + type: 'boolean', + description: + 'If true, an invite code must be supplied to create an account on this instance.', }, - rkey: { - type: 'string', - description: 'The key of the record.', + phoneVerificationRequired: { + type: 'boolean', + description: + 'If true, a phone verification token must be supplied to create an account on this instance.', }, - swapRecord: { - type: 'string', - format: 'cid', + availableUserDomains: { + type: 'array', description: - 'Compare and swap with the previous record by CID.', + 'List of domain suffixes that can be used in account handles.', + items: { + type: 'string', + }, }, - swapCommit: { + links: { + type: 'ref', + description: 'URLs of service policy documents.', + ref: 'lex:com.atproto.server.describeServer#links', + }, + contact: { + type: 'ref', + description: 'Contact information', + ref: 'lex:com.atproto.server.describeServer#contact', + }, + did: { type: 'string', - format: 'cid', - description: - 'Compare and swap with the previous commit by CID.', + format: 'did', + }, + }, + }, + }, + }, + links: { + type: 'object', + properties: { + privacyPolicy: { + type: 'string', + }, + termsOfService: { + type: 'string', + }, + }, + }, + contact: { + type: 'object', + properties: { + email: { + type: 'string', + }, + }, + }, + }, + }, + ComAtprotoServerGetAccountInviteCodes: { + lexicon: 1, + id: 'com.atproto.server.getAccountInviteCodes', + defs: { + main: { + type: 'query', + description: + 'Get all invite codes for the current account. Requires auth.', + parameters: { + type: 'params', + properties: { + includeUsed: { + type: 'boolean', + default: true, + }, + createAvailable: { + type: 'boolean', + default: true, + description: + "Controls whether any new 'earned' but not 'created' invites should be created.", + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['codes'], + properties: { + codes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, }, }, }, }, errors: [ { - name: 'InvalidSwap', + name: 'DuplicateCreate', }, ], }, }, }, - ComAtprotoRepoDescribeRepo: { + ComAtprotoServerGetServiceAuth: { lexicon: 1, - id: 'com.atproto.repo.describeRepo', + id: 'com.atproto.server.getServiceAuth', defs: { main: { type: 'query', description: - 'Get information about the repo, including the list of collections.', + 'Get a signed token on behalf of the requesting DID for the requested service.', parameters: { type: 'params', - required: ['repo'], + required: ['aud'], properties: { - repo: { + aud: { type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', + format: 'did', + description: + 'The DID of the service that the token will be used to authenticate with', }, }, }, @@ -2486,34 +2525,10 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: [ - 'handle', - 'did', - 'didDoc', - 'collections', - 'handleIsCorrect', - ], + required: ['token'], properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { + token: { type: 'string', - format: 'did', - }, - didDoc: { - type: 'unknown', - }, - collections: { - type: 'array', - items: { - type: 'string', - format: 'nsid', - }, - }, - handleIsCorrect: { - type: 'boolean', }, }, }, @@ -2521,54 +2536,35 @@ export const schemaDict = { }, }, }, - ComAtprotoRepoGetRecord: { + ComAtprotoServerGetSession: { lexicon: 1, - id: 'com.atproto.repo.getRecord', + id: 'com.atproto.server.getSession', defs: { main: { type: 'query', - description: 'Get a record.', - parameters: { - type: 'params', - required: ['repo', 'collection', 'rkey'], - properties: { - repo: { - type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', - }, - collection: { - type: 'string', - format: 'nsid', - description: 'The NSID of the record collection.', - }, - rkey: { - type: 'string', - description: 'The key of the record.', - }, - cid: { - type: 'string', - format: 'cid', - description: - 'The CID of the version of the record. If not specified, then return the most recent version.', - }, - }, - }, + description: + 'Get information about the current auth session. Requires auth.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['uri', 'value'], + required: ['handle', 'did'], properties: { - uri: { + handle: { type: 'string', - format: 'at-uri', + format: 'handle', }, - cid: { + did: { type: 'string', - format: 'cid', + format: 'did', }, - value: { + email: { + type: 'string', + }, + emailConfirmed: { + type: 'boolean', + }, + didDoc: { type: 'unknown', }, }, @@ -2577,210 +2573,188 @@ export const schemaDict = { }, }, }, - ComAtprotoRepoListRecords: { + ComAtprotoServerListAppPasswords: { lexicon: 1, - id: 'com.atproto.repo.listRecords', + id: 'com.atproto.server.listAppPasswords', defs: { main: { type: 'query', - description: 'List a range of records in a collection.', - parameters: { - type: 'params', - required: ['repo', 'collection'], - properties: { - repo: { - type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', - }, - collection: { - type: 'string', - format: 'nsid', - description: 'The NSID of the record type.', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - description: 'The number of records to return.', - }, - cursor: { - type: 'string', - }, - rkeyStart: { - type: 'string', - description: - 'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)', - }, - rkeyEnd: { - type: 'string', - description: - 'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)', - }, - reverse: { - type: 'boolean', - description: 'Flag to reverse the order of the returned records.', - }, - }, - }, + description: 'List all App Passwords.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['records'], + required: ['passwords'], properties: { - cursor: { - type: 'string', - }, - records: { + passwords: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.repo.listRecords#record', + ref: 'lex:com.atproto.server.listAppPasswords#appPassword', }, }, }, }, }, + errors: [ + { + name: 'AccountTakedown', + }, + ], }, - record: { + appPassword: { type: 'object', - required: ['uri', 'cid', 'value'], + required: ['name', 'createdAt'], properties: { - uri: { + name: { type: 'string', - format: 'at-uri', }, - cid: { + createdAt: { type: 'string', - format: 'cid', - }, - value: { - type: 'unknown', + format: 'datetime', }, }, }, }, }, - ComAtprotoRepoPutRecord: { + ComAtprotoServerRefreshSession: { lexicon: 1, - id: 'com.atproto.repo.putRecord', + id: 'com.atproto.server.refreshSession', defs: { main: { type: 'procedure', - description: 'Write a record, creating or updating it as needed.', - input: { + description: + "Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').", + output: { encoding: 'application/json', schema: { type: 'object', - required: ['repo', 'collection', 'rkey', 'record'], - nullable: ['swapRecord'], + required: ['accessJwt', 'refreshJwt', 'handle', 'did'], properties: { - repo: { + accessJwt: { type: 'string', - format: 'at-identifier', - description: 'The handle or DID of the repo.', }, - collection: { + refreshJwt: { type: 'string', - format: 'nsid', - description: 'The NSID of the record collection.', }, - rkey: { + handle: { type: 'string', - description: 'The key of the record.', - maxLength: 15, - }, - validate: { - type: 'boolean', - default: true, - description: 'Flag for validating the record.', - }, - record: { - type: 'unknown', - description: 'The record to write.', + format: 'handle', }, - swapRecord: { + did: { type: 'string', - format: 'cid', - description: - 'Compare and swap with the previous record by CID.', + format: 'did', }, - swapCommit: { - type: 'string', - format: 'cid', - description: - 'Compare and swap with the previous commit by CID.', + didDoc: { + type: 'unknown', }, }, }, }, + errors: [ + { + name: 'AccountTakedown', + }, + ], + }, + }, + }, + ComAtprotoServerRequestAccountDelete: { + lexicon: 1, + id: 'com.atproto.server.requestAccountDelete', + defs: { + main: { + type: 'procedure', + description: 'Initiate a user account deletion via email.', + }, + }, + }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email.', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['uri', 'cid'], + required: ['tokenRequired'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', + tokenRequired: { + type: 'boolean', }, }, }, }, - errors: [ - { - name: 'InvalidSwap', - }, - ], }, }, }, - ComAtprotoRepoStrongRef: { + ComAtprotoServerRequestPasswordReset: { lexicon: 1, - id: 'com.atproto.repo.strongRef', - description: 'A URI with a content-hash fingerprint.', + id: 'com.atproto.server.requestPasswordReset', defs: { main: { - type: 'object', - required: ['uri', 'cid'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', + type: 'procedure', + description: 'Initiate a user account password reset via email.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + }, }, }, }, }, }, - ComAtprotoRepoUploadBlob: { + ComAtprotoServerReserveSigningKey: { lexicon: 1, - id: 'com.atproto.repo.uploadBlob', + id: 'com.atproto.server.reserveSigningKey', defs: { main: { type: 'procedure', description: - 'Upload a new blob to be added to repo in a later request.', + 'Reserve a repo signing key, for use with account creation. Necessary so that a DID PLC update operation can be constructed during an account migraiton. Public and does not require auth; implemented by PDS. NOTE: this endpoint may change when full account migration is implemented.', input: { - encoding: '*/*', + encoding: 'application/json', + schema: { + type: 'object', + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID to reserve a key for.', + }, + }, + }, }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['blob'], + required: ['signingKey'], properties: { - blob: { - type: 'blob', + signingKey: { + type: 'string', + description: + 'The public key for the reserved signing key, in did:key serialization.', }, }, }, @@ -2788,714 +2762,812 @@ export const schemaDict = { }, }, }, - ComAtprotoServerConfirmEmail: { + ComAtprotoServerResetPassword: { lexicon: 1, - id: 'com.atproto.server.confirmEmail', + id: 'com.atproto.server.resetPassword', defs: { main: { type: 'procedure', - description: - 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + description: 'Reset a user account password using a token.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['email', 'token'], + required: ['token', 'password'], properties: { - email: { + token: { type: 'string', }, - token: { + password: { type: 'string', }, }, }, }, errors: [ - { - name: 'AccountNotFound', - }, { name: 'ExpiredToken', }, { name: 'InvalidToken', }, - { - name: 'InvalidEmail', - }, ], }, }, }, - ComAtprotoServerCreateAccount: { + ComAtprotoServerRevokeAppPassword: { lexicon: 1, - id: 'com.atproto.server.createAccount', + id: 'com.atproto.server.revokeAppPassword', defs: { main: { type: 'procedure', - description: 'Create an account.', + description: 'Revoke an App Password by name.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['handle'], + required: ['name'], properties: { - email: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - inviteCode: { - type: 'string', - }, - verificationCode: { - type: 'string', - }, - verificationPhone: { - type: 'string', - }, - password: { - type: 'string', - }, - recoveryKey: { + name: { type: 'string', }, - plcOp: { - type: 'unknown', - }, }, }, }, - output: { + }, + }, + }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { encoding: 'application/json', schema: { type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], + required: ['email'], properties: { - accessJwt: { + email: { type: 'string', }, - refreshJwt: { + token: { type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", }, - handle: { + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, + ComAtprotoSyncGetBlob: { + lexicon: 1, + id: 'com.atproto.sync.getBlob', + defs: { + main: { + type: 'query', + description: + 'Get a blob associated with a given account. Returns the full blob as originally uploaded. Does not require auth; implemented by PDS.', + parameters: { + type: 'params', + required: ['did', 'cid'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the account.', + }, + cid: { + type: 'string', + format: 'cid', + description: 'The CID of the blob to fetch', + }, + }, + }, + output: { + encoding: '*/*', + }, + }, + }, + }, + ComAtprotoSyncGetBlocks: { + lexicon: 1, + id: 'com.atproto.sync.getBlocks', + defs: { + main: { + type: 'query', + description: + 'Get data blocks from a given repo, by CID. For example, intermediate MST nodes, or records. Does not require auth; implemented by PDS.', + parameters: { + type: 'params', + required: ['did', 'cids'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + cids: { + type: 'array', + items: { type: 'string', - format: 'handle', + format: 'cid', }, - did: { + }, + }, + }, + output: { + encoding: 'application/vnd.ipld.car', + }, + }, + }, + }, + ComAtprotoSyncGetCheckout: { + lexicon: 1, + id: 'com.atproto.sync.getCheckout', + defs: { + main: { + type: 'query', + description: 'DEPRECATED - please use com.atproto.sync.getRepo instead', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + }, + }, + output: { + encoding: 'application/vnd.ipld.car', + }, + }, + }, + }, + ComAtprotoSyncGetHead: { + lexicon: 1, + id: 'com.atproto.sync.getHead', + defs: { + main: { + type: 'query', + description: + 'DEPRECATED - please use com.atproto.sync.getLatestCommit instead', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['root'], + properties: { + root: { type: 'string', - format: 'did', - }, - didDoc: { - type: 'unknown', + format: 'cid', }, }, }, }, errors: [ { - name: 'InvalidHandle', - }, - { - name: 'InvalidPassword', - }, - { - name: 'InvalidInviteCode', - }, - { - name: 'HandleNotAvailable', - }, - { - name: 'UnsupportedDomain', - }, - { - name: 'UnresolvableDid', - }, - { - name: 'IncompatibleDidDoc', + name: 'HeadNotFound', }, ], }, }, }, - ComAtprotoServerCreateAppPassword: { + ComAtprotoSyncGetLatestCommit: { lexicon: 1, - id: 'com.atproto.server.createAppPassword', + id: 'com.atproto.sync.getLatestCommit', defs: { main: { - type: 'procedure', - description: 'Create an App Password.', - input: { + type: 'query', + description: + 'Get the current commit CID & revision of the specified repo. Does not require auth.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + }, + }, + output: { encoding: 'application/json', schema: { type: 'object', - required: ['name'], + required: ['cid', 'rev'], properties: { - name: { + cid: { + type: 'string', + format: 'cid', + }, + rev: { type: 'string', }, }, }, }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:com.atproto.server.createAppPassword#appPassword', - }, - }, errors: [ { - name: 'AccountTakedown', + name: 'RepoNotFound', }, ], }, - appPassword: { - type: 'object', - required: ['name', 'password', 'createdAt'], - properties: { - name: { - type: 'string', - }, - password: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'datetime', + }, + }, + ComAtprotoSyncGetRecord: { + lexicon: 1, + id: 'com.atproto.sync.getRecord', + defs: { + main: { + type: 'query', + description: + 'Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.', + parameters: { + type: 'params', + required: ['did', 'collection', 'rkey'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + collection: { + type: 'string', + format: 'nsid', + }, + rkey: { + type: 'string', + description: 'Record Key', + }, + commit: { + type: 'string', + format: 'cid', + description: 'An optional past commit CID.', + }, }, }, + output: { + encoding: 'application/vnd.ipld.car', + }, }, }, }, - ComAtprotoServerCreateInviteCode: { + ComAtprotoSyncGetRepo: { lexicon: 1, - id: 'com.atproto.server.createInviteCode', + id: 'com.atproto.sync.getRepo', defs: { main: { - type: 'procedure', - description: 'Create an invite code.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['useCount'], - properties: { - useCount: { - type: 'integer', - }, - forAccount: { - type: 'string', - format: 'did', - }, + type: 'query', + description: + "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + since: { + type: 'string', + description: + "The revision ('rev') of the repo to create a diff from.", }, }, }, output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['code'], - properties: { - code: { - type: 'string', - }, - }, - }, + encoding: 'application/vnd.ipld.car', }, }, }, }, - ComAtprotoServerCreateInviteCodes: { + ComAtprotoSyncListBlobs: { lexicon: 1, - id: 'com.atproto.server.createInviteCodes', + id: 'com.atproto.sync.listBlobs', defs: { main: { - type: 'procedure', - description: 'Create invite codes.', - input: { + type: 'query', + description: + 'List blob CIDso for an account, since some repo revision. Does not require auth; implemented by PDS.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + description: 'The DID of the repo.', + }, + since: { + type: 'string', + description: 'Optional revision of the repo to list blobs since.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 500, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { encoding: 'application/json', schema: { type: 'object', - required: ['codeCount', 'useCount'], + required: ['cids'], properties: { - codeCount: { - type: 'integer', - default: 1, - }, - useCount: { - type: 'integer', + cursor: { + type: 'string', }, - forAccounts: { + cids: { type: 'array', items: { type: 'string', - format: 'did', + format: 'cid', }, }, }, }, }, + }, + }, + }, + ComAtprotoSyncListRepos: { + lexicon: 1, + id: 'com.atproto.sync.listRepos', + defs: { + main: { + type: 'query', + description: + 'Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 500, + }, + cursor: { + type: 'string', + }, + }, + }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['codes'], + required: ['repos'], properties: { - codes: { + cursor: { + type: 'string', + }, + repos: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.createInviteCodes#accountCodes', + ref: 'lex:com.atproto.sync.listRepos#repo', }, }, }, }, }, }, - accountCodes: { + repo: { type: 'object', - required: ['account', 'codes'], + required: ['did', 'head', 'rev'], properties: { - account: { + did: { + type: 'string', + format: 'did', + }, + head: { + type: 'string', + format: 'cid', + description: 'Current repo commit CID', + }, + rev: { type: 'string', }, - codes: { - type: 'array', - items: { - type: 'string', - }, - }, }, }, }, }, - ComAtprotoServerCreateSession: { + ComAtprotoSyncNotifyOfUpdate: { lexicon: 1, - id: 'com.atproto.server.createSession', + id: 'com.atproto.sync.notifyOfUpdate', defs: { main: { type: 'procedure', - description: 'Create an authentication session.', + description: + 'Notify a crawling service of a recent update, and that crawling should resume. Intended use is after a gap between repo stream events caused the crawling service to disconnect. Does not require auth; implemented by Relay.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['identifier', 'password'], + required: ['hostname'], properties: { - identifier: { + hostname: { type: 'string', description: - 'Handle or other identifier supported by the server for the authenticating user.', - }, - password: { - type: 'string', + 'Hostname of the current service (usually a PDS) that is notifying of update.', }, }, }, }, - output: { + }, + }, + }, + ComAtprotoSyncRequestCrawl: { + lexicon: 1, + id: 'com.atproto.sync.requestCrawl', + defs: { + main: { + type: 'procedure', + description: + 'Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.', + input: { encoding: 'application/json', schema: { type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], + required: ['hostname'], properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - didDoc: { - type: 'unknown', - }, - email: { + hostname: { type: 'string', - }, - emailConfirmed: { - type: 'boolean', + description: + 'Hostname of the current service (eg, PDS) that is requesting to be crawled.', }, }, }, }, - errors: [ - { - name: 'AccountTakedown', - }, - ], }, }, }, - ComAtprotoServerDefs: { + ComAtprotoSyncSubscribeRepos: { lexicon: 1, - id: 'com.atproto.server.defs', + id: 'com.atproto.sync.subscribeRepos', defs: { - inviteCode: { + main: { + type: 'subscription', + description: + 'Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.', + parameters: { + type: 'params', + properties: { + cursor: { + type: 'integer', + description: 'The last known event seq number to backfill from.', + }, + }, + }, + message: { + schema: { + type: 'union', + refs: [ + 'lex:com.atproto.sync.subscribeRepos#commit', + 'lex:com.atproto.sync.subscribeRepos#identity', + 'lex:com.atproto.sync.subscribeRepos#handle', + 'lex:com.atproto.sync.subscribeRepos#migrate', + 'lex:com.atproto.sync.subscribeRepos#tombstone', + 'lex:com.atproto.sync.subscribeRepos#info', + ], + }, + }, + errors: [ + { + name: 'FutureCursor', + }, + { + name: 'ConsumerTooSlow', + description: + 'If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection.', + }, + ], + }, + commit: { type: 'object', + description: + 'Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature.', required: [ - 'code', - 'available', - 'disabled', - 'forAccount', - 'createdBy', - 'createdAt', - 'uses', + 'seq', + 'rebase', + 'tooBig', + 'repo', + 'commit', + 'rev', + 'since', + 'blocks', + 'ops', + 'blobs', + 'time', ], + nullable: ['prev', 'since'], properties: { - code: { - type: 'string', - }, - available: { + seq: { type: 'integer', + description: 'The stream sequence number of this message.', }, - disabled: { + rebase: { type: 'boolean', + description: 'DEPRECATED -- unused', }, - forAccount: { + tooBig: { + type: 'boolean', + description: + 'Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data.', + }, + repo: { type: 'string', + format: 'did', + description: 'The repo this event comes from.', }, - createdBy: { + commit: { + type: 'cid-link', + description: 'Repo commit object CID.', + }, + prev: { + type: 'cid-link', + description: + 'DEPRECATED -- unused. WARNING -- nullable and optional; stick with optional to ensure golang interoperability.', + }, + rev: { type: 'string', + description: + 'The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event.', }, - createdAt: { + since: { type: 'string', - format: 'datetime', + description: + 'The rev of the last emitted commit from this repo (if any).', }, - uses: { + blocks: { + type: 'bytes', + description: + 'CAR file containing relevant blocks, as a diff since the previous repo state.', + maxLength: 1000000, + }, + ops: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCodeUse', + ref: 'lex:com.atproto.sync.subscribeRepos#repoOp', + description: + 'List of repo mutation operations in this commit (eg, records created, updated, or deleted).', }, + maxLength: 200, }, - }, - }, - inviteCodeUse: { - type: 'object', - required: ['usedBy', 'usedAt'], - properties: { - usedBy: { - type: 'string', - format: 'did', - }, - usedAt: { - type: 'string', - format: 'datetime', - }, - }, - }, - }, - }, - ComAtprotoServerDeleteAccount: { - lexicon: 1, - id: 'com.atproto.server.deleteAccount', - defs: { - main: { - type: 'procedure', - description: "Delete an actor's account with a token and password.", - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['did', 'password', 'token'], - properties: { - did: { - type: 'string', - format: 'did', - }, - password: { - type: 'string', - }, - token: { - type: 'string', - }, + blobs: { + type: 'array', + items: { + type: 'cid-link', + description: + 'List of new blobs (by CID) referenced by records in this commit.', }, }, - }, - errors: [ - { - name: 'ExpiredToken', - }, - { - name: 'InvalidToken', - }, - ], - }, - }, - }, - ComAtprotoServerDeleteSession: { - lexicon: 1, - id: 'com.atproto.server.deleteSession', - defs: { - main: { - type: 'procedure', - description: 'Delete the current session.', - }, - }, - }, - ComAtprotoServerDescribeServer: { - lexicon: 1, - id: 'com.atproto.server.describeServer', - defs: { - main: { - type: 'query', - description: - "Get a document describing the service's accounts configuration.", - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['availableUserDomains'], - properties: { - inviteCodeRequired: { - type: 'boolean', - }, - phoneVerificationRequired: { - type: 'boolean', - }, - availableUserDomains: { - type: 'array', - items: { - type: 'string', - }, - }, - links: { - type: 'ref', - ref: 'lex:com.atproto.server.describeServer#links', - }, - }, + time: { + type: 'string', + format: 'datetime', + description: + 'Timestamp of when this message was originally broadcast.', }, }, }, - links: { + identity: { type: 'object', + description: + "Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache.", + required: ['seq', 'did', 'time'], properties: { - privacyPolicy: { + seq: { + type: 'integer', + }, + did: { type: 'string', + format: 'did', }, - termsOfService: { + time: { type: 'string', + format: 'datetime', }, }, }, - }, - }, - ComAtprotoServerGetAccountInviteCodes: { - lexicon: 1, - id: 'com.atproto.server.getAccountInviteCodes', - defs: { - main: { - type: 'query', - description: 'Get all invite codes for a given account.', - parameters: { - type: 'params', - properties: { - includeUsed: { - type: 'boolean', - default: true, - }, - createAvailable: { - type: 'boolean', - default: true, - }, + handle: { + type: 'object', + description: + "Represents an update of the account's handle, or transition to/from invalid state. NOTE: Will be deprecated in favor of #identity.", + required: ['seq', 'did', 'handle', 'time'], + properties: { + seq: { + type: 'integer', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['codes'], - properties: { - codes: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.defs#inviteCode', - }, - }, - }, + did: { + type: 'string', + format: 'did', }, - }, - errors: [ - { - name: 'DuplicateCreate', + handle: { + type: 'string', + format: 'handle', }, - ], - }, - }, - }, - ComAtprotoServerGetSession: { - lexicon: 1, - id: 'com.atproto.server.getSession', - defs: { - main: { - type: 'query', - description: 'Get information about the current session.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - email: { - type: 'string', - }, - emailConfirmed: { - type: 'boolean', - }, - didDoc: { - type: 'unknown', - }, - }, + time: { + type: 'string', + format: 'datetime', }, }, }, - }, - }, - ComAtprotoServerListAppPasswords: { - lexicon: 1, - id: 'com.atproto.server.listAppPasswords', - defs: { - main: { - type: 'query', - description: 'List all App Passwords.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['passwords'], - properties: { - passwords: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.server.listAppPasswords#appPassword', - }, - }, - }, + migrate: { + type: 'object', + description: + 'Represents an account moving from one PDS instance to another. NOTE: not implemented; account migration uses #identity instead', + required: ['seq', 'did', 'migrateTo', 'time'], + nullable: ['migrateTo'], + properties: { + seq: { + type: 'integer', + }, + did: { + type: 'string', + format: 'did', + }, + migrateTo: { + type: 'string', + }, + time: { + type: 'string', + format: 'datetime', }, }, - errors: [ - { - name: 'AccountTakedown', + }, + tombstone: { + type: 'object', + description: + 'Indicates that an account has been deleted. NOTE: may be deprecated in favor of #identity or a future #account event', + required: ['seq', 'did', 'time'], + properties: { + seq: { + type: 'integer', }, - ], + did: { + type: 'string', + format: 'did', + }, + time: { + type: 'string', + format: 'datetime', + }, + }, }, - appPassword: { + info: { type: 'object', - required: ['name', 'createdAt'], + required: ['name'], properties: { name: { type: 'string', + knownValues: ['OutdatedCursor'], }, - createdAt: { + message: { type: 'string', - format: 'datetime', + }, + }, + }, + repoOp: { + type: 'object', + description: 'A repo operation, ie a mutation of a single record.', + required: ['action', 'path', 'cid'], + nullable: ['cid'], + properties: { + action: { + type: 'string', + knownValues: ['create', 'update', 'delete'], + }, + path: { + type: 'string', + }, + cid: { + type: 'cid-link', + description: + 'For creates and updates, the new record CID. For deletions, null.', }, }, }, }, }, - ComAtprotoServerRefreshSession: { + ComAtprotoTempCheckSignupQueue: { lexicon: 1, - id: 'com.atproto.server.refreshSession', + id: 'com.atproto.temp.checkSignupQueue', defs: { main: { - type: 'procedure', - description: 'Refresh an authentication session.', + type: 'query', + description: 'Check accounts location in signup queue.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], + required: ['activated'], properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', + activated: { + type: 'boolean', }, - did: { - type: 'string', - format: 'did', + placeInQueue: { + type: 'integer', }, - didDoc: { - type: 'unknown', + estimatedTimeMs: { + type: 'integer', }, }, }, }, - errors: [ - { - name: 'AccountTakedown', - }, - ], - }, - }, - }, - ComAtprotoServerRequestAccountDelete: { - lexicon: 1, - id: 'com.atproto.server.requestAccountDelete', - defs: { - main: { - type: 'procedure', - description: 'Initiate a user account deletion via email.', }, }, }, - ComAtprotoServerRequestEmailConfirmation: { + ComAtprotoTempFetchLabels: { lexicon: 1, - id: 'com.atproto.server.requestEmailConfirmation', + id: 'com.atproto.temp.fetchLabels', defs: { main: { - type: 'procedure', + type: 'query', description: - 'Request an email with a code to confirm ownership of email.', - }, - }, - }, - ComAtprotoServerRequestEmailUpdate: { - lexicon: 1, - id: 'com.atproto.server.requestEmailUpdate', - defs: { - main: { - type: 'procedure', - description: 'Request a token in order to update email.', + 'DEPRECATED: use queryLabels or subscribeLabels instead -- Fetch all labels from a labeler created after a certain date.', + parameters: { + type: 'params', + properties: { + since: { + type: 'integer', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 250, + default: 50, + }, + }, + }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['tokenRequired'], + required: ['labels'], properties: { - tokenRequired: { - type: 'boolean', + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, }, }, }, @@ -3503,295 +3575,521 @@ export const schemaDict = { }, }, }, - ComAtprotoServerRequestPasswordReset: { + ComAtprotoTempRequestPhoneVerification: { lexicon: 1, - id: 'com.atproto.server.requestPasswordReset', + id: 'com.atproto.temp.requestPhoneVerification', defs: { main: { type: 'procedure', - description: 'Initiate a user account password reset via email.', + description: + 'Request a verification code to be sent to the supplied phone number', input: { encoding: 'application/json', schema: { type: 'object', - required: ['email'], + required: ['phoneNumber'], properties: { - email: { + phoneNumber: { type: 'string', }, }, }, }, }, - }, - }, - ComAtprotoServerReserveSigningKey: { - lexicon: 1, - id: 'com.atproto.server.reserveSigningKey', - defs: { - main: { - type: 'procedure', - description: 'Reserve a repo signing key for account creation.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - properties: { - did: { - type: 'string', - description: 'The did to reserve a new did:key for', - }, + }, + }, + AppBskyActorDefs: { + lexicon: 1, + id: 'app.bsky.actor.defs', + defs: { + profileViewBasic: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + displayName: { + type: 'string', + maxGraphemes: 64, + maxLength: 640, + }, + avatar: { + type: 'string', + }, + associated: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileAssociated', + }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#viewerState', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + profileView: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + displayName: { + type: 'string', + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: 'string', + maxGraphemes: 256, + maxLength: 2560, + }, + avatar: { + type: 'string', + }, + associated: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileAssociated', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#viewerState', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + profileViewDetailed: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + displayName: { + type: 'string', + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: 'string', + maxGraphemes: 256, + maxLength: 2560, + }, + avatar: { + type: 'string', + }, + banner: { + type: 'string', + }, + followersCount: { + type: 'integer', + }, + followsCount: { + type: 'integer', + }, + postsCount: { + type: 'integer', + }, + associated: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileAssociated', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#viewerState', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + 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.", + properties: { + muted: { + type: 'boolean', + }, + mutedByList: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listViewBasic', + }, + blockedBy: { + type: 'boolean', + }, + blocking: { + type: 'string', + format: 'at-uri', + }, + blockingByList: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listViewBasic', + }, + following: { + type: 'string', + format: 'at-uri', + }, + followedBy: { + type: 'string', + format: 'at-uri', + }, + }, + }, + preferences: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:app.bsky.actor.defs#adultContentPref', + 'lex:app.bsky.actor.defs#contentLabelPref', + 'lex:app.bsky.actor.defs#savedFeedsPref', + 'lex:app.bsky.actor.defs#personalDetailsPref', + 'lex:app.bsky.actor.defs#feedViewPref', + 'lex:app.bsky.actor.defs#threadViewPref', + 'lex:app.bsky.actor.defs#interestsPref', + 'lex:app.bsky.actor.defs#mutedWordsPref', + 'lex:app.bsky.actor.defs#hiddenPostsPref', + ], + }, + }, + adultContentPref: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { + type: 'boolean', + default: false, + }, + }, + }, + contentLabelPref: { + 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: ['ignore', 'show', 'warn', 'hide'], + }, + }, + }, + savedFeedsPref: { + type: 'object', + required: ['pinned', 'saved'], + properties: { + pinned: { + type: 'array', + items: { + type: 'string', + format: 'at-uri', }, }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['signingKey'], - properties: { - signingKey: { - type: 'string', - description: 'Public signing key in the form of a did:key.', - }, + saved: { + type: 'array', + items: { + type: 'string', + format: 'at-uri', }, }, + timelineIndex: { + type: 'integer', + }, }, }, - }, - }, - ComAtprotoServerResetPassword: { - lexicon: 1, - id: 'com.atproto.server.resetPassword', - defs: { - main: { - type: 'procedure', - description: 'Reset a user account password using a token.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['token', 'password'], - properties: { - token: { - type: 'string', - }, - password: { - type: 'string', - }, - }, + personalDetailsPref: { + type: 'object', + properties: { + birthDate: { + type: 'string', + format: 'datetime', + description: 'The birth date of account owner.', }, }, - errors: [ - { - name: 'ExpiredToken', + }, + feedViewPref: { + type: 'object', + required: ['feed'], + properties: { + feed: { + type: 'string', + description: + 'The URI of the feed, or an identifier which describes the feed.', }, - { - name: 'InvalidToken', + hideReplies: { + type: 'boolean', + description: 'Hide replies in the feed.', }, - ], + hideRepliesByUnfollowed: { + type: 'boolean', + description: + 'Hide replies in the feed if they are not by followed users.', + default: true, + }, + hideRepliesByLikeCount: { + type: 'integer', + description: + 'Hide replies in the feed if they do not have this number of likes.', + }, + hideReposts: { + type: 'boolean', + description: 'Hide reposts in the feed.', + }, + hideQuotePosts: { + type: 'boolean', + description: 'Hide quote posts in the feed.', + }, + }, }, - }, - }, - ComAtprotoServerRevokeAppPassword: { - lexicon: 1, - id: 'com.atproto.server.revokeAppPassword', - defs: { - main: { - type: 'procedure', - description: 'Revoke an App Password by name.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['name'], - properties: { - name: { - type: 'string', - }, - }, + threadViewPref: { + type: 'object', + properties: { + sort: { + type: 'string', + description: 'Sorting mode for threads.', + knownValues: ['oldest', 'newest', 'most-likes', 'random'], + }, + prioritizeFollowedUsers: { + type: 'boolean', + description: 'Show followed users at the top of all replies.', }, }, }, - }, - }, - ComAtprotoServerUpdateEmail: { - lexicon: 1, - id: 'com.atproto.server.updateEmail', - defs: { - main: { - type: 'procedure', - description: "Update an account's email.", - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['email'], - properties: { - email: { - type: 'string', - }, - token: { - type: 'string', - description: - "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", - }, + interestsPref: { + type: 'object', + required: ['tags'], + properties: { + tags: { + type: 'array', + maxLength: 100, + items: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, }, + description: + "A list of tags which describe the account owner's interests gathered during onboarding.", }, }, - errors: [ - { - name: 'ExpiredToken', - }, - { - name: 'InvalidToken', + }, + mutedWordTarget: { + type: 'string', + knownValues: ['content', 'tag'], + maxLength: 640, + maxGraphemes: 64, + }, + mutedWord: { + type: 'object', + description: 'A word that the account owner has muted.', + required: ['value', 'targets'], + properties: { + value: { + type: 'string', + description: 'The muted word itself.', + maxLength: 10000, + maxGraphemes: 1000, }, - { - name: 'TokenRequired', + targets: { + type: 'array', + description: 'The intended targets of the muted word.', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#mutedWordTarget', + }, }, - ], + }, }, - }, - }, - ComAtprotoSyncGetBlob: { - lexicon: 1, - id: 'com.atproto.sync.getBlob', - defs: { - main: { - type: 'query', - description: 'Get a blob associated with a given repo.', - parameters: { - type: 'params', - required: ['did', 'cid'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - cid: { - type: 'string', - format: 'cid', - description: 'The CID of the blob to fetch', + mutedWordsPref: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#mutedWord', }, + description: 'A list of words the account owner has muted.', }, }, - output: { - encoding: '*/*', - }, }, - }, - }, - ComAtprotoSyncGetBlocks: { - lexicon: 1, - id: 'com.atproto.sync.getBlocks', - defs: { - main: { - type: 'query', - description: 'Get blocks from a given repo.', - parameters: { - type: 'params', - required: ['did', 'cids'], - properties: { - did: { + hiddenPostsPref: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { type: 'string', - format: 'did', - description: 'The DID of the repo.', + format: 'at-uri', }, - cids: { - type: 'array', - items: { - type: 'string', - format: 'cid', - }, + description: + 'A list of URIs of posts the account owner has hidden.', + }, + }, + }, + labelersPref: { + type: 'object', + required: ['labelers'], + properties: { + labelers: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#labelerPrefItem', }, }, }, - output: { - encoding: 'application/vnd.ipld.car', + }, + labelerPrefItem: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, }, }, }, }, - ComAtprotoSyncGetCheckout: { + AppBskyActorGetPreferences: { lexicon: 1, - id: 'com.atproto.sync.getCheckout', + id: 'app.bsky.actor.getPreferences', defs: { main: { type: 'query', - description: 'DEPRECATED - please use com.atproto.sync.getRepo instead', + description: + 'Get private preferences attached to the current account. Expected use is synchronization between multiple devices, and import/export during account migration. Requires auth.', parameters: { type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, + properties: {}, }, output: { - encoding: 'application/vnd.ipld.car', + encoding: 'application/json', + schema: { + type: 'object', + required: ['preferences'], + properties: { + preferences: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#preferences', + }, + }, + }, }, }, }, }, - ComAtprotoSyncGetHead: { + AppBskyActorGetProfile: { lexicon: 1, - id: 'com.atproto.sync.getHead', + id: 'app.bsky.actor.getProfile', defs: { main: { type: 'query', description: - 'DEPRECATED - please use com.atproto.sync.getLatestCommit instead', + 'Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth.', parameters: { type: 'params', - required: ['did'], + required: ['actor'], properties: { - did: { + actor: { type: 'string', - format: 'did', - description: 'The DID of the repo.', + format: 'at-identifier', + description: 'Handle or DID of account to fetch profile of.', }, }, }, output: { encoding: 'application/json', schema: { - type: 'object', - required: ['root'], - properties: { - root: { - type: 'string', - format: 'cid', - }, - }, + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewDetailed', }, }, - errors: [ - { - name: 'HeadNotFound', - }, - ], }, }, }, - ComAtprotoSyncGetLatestCommit: { + AppBskyActorGetProfiles: { lexicon: 1, - id: 'com.atproto.sync.getLatestCommit', + id: 'app.bsky.actor.getProfiles', defs: { main: { type: 'query', - description: 'Get the current commit CID & revision of the repo.', + description: 'Get detailed profile views of multiple actors.', parameters: { type: 'params', - required: ['did'], + required: ['actors'], properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', + actors: { + type: 'array', + items: { + type: 'string', + format: 'at-identifier', + }, + maxLength: 25, }, }, }, @@ -3799,138 +4097,128 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['cid', 'rev'], + required: ['profiles'], properties: { - cid: { - type: 'string', - format: 'cid', - }, - rev: { - type: 'string', + profiles: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewDetailed', + }, }, }, }, }, - errors: [ - { - name: 'RepoNotFound', - }, - ], }, }, }, - ComAtprotoSyncGetRecord: { + AppBskyActorGetSuggestions: { lexicon: 1, - id: 'com.atproto.sync.getRecord', + id: 'app.bsky.actor.getSuggestions', defs: { main: { type: 'query', description: - 'Get blocks needed for existence or non-existence of record.', + 'Get a list of suggested actors. Expected use is discovery of accounts to follow during new account onboarding.', parameters: { type: 'params', - required: ['did', 'collection', 'rkey'], properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - collection: { - type: 'string', - format: 'nsid', - }, - rkey: { - type: 'string', + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, }, - commit: { + cursor: { type: 'string', - format: 'cid', - description: 'An optional past commit CID.', }, }, }, output: { - encoding: 'application/vnd.ipld.car', + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + cursor: { + type: 'string', + }, + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + }, + }, + }, }, }, }, }, - ComAtprotoSyncGetRepo: { + AppBskyActorProfile: { lexicon: 1, - id: 'com.atproto.sync.getRepo', + id: 'app.bsky.actor.profile', defs: { main: { - type: 'query', - description: - "Gets the DID's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], + type: 'record', + description: 'A declaration of a Bluesky account profile.', + key: 'literal:self', + record: { + type: 'object', properties: { - did: { + displayName: { type: 'string', - format: 'did', - description: 'The DID of the repo.', + maxGraphemes: 64, + maxLength: 640, }, - since: { + description: { type: 'string', - description: 'The revision of the repo to catch up from.', + description: 'Free-form profile description text.', + maxGraphemes: 256, + maxLength: 2560, + }, + avatar: { + type: 'blob', + description: + "Small image to be displayed next to posts from account. AKA, 'profile picture'", + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + banner: { + type: 'blob', + description: + 'Larger horizontal image to display behind profile view.', + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + labels: { + type: 'union', + description: + 'Self-label values, specific to the Bluesky application, on the overall account.', + refs: ['lex:com.atproto.label.defs#selfLabels'], }, }, }, - output: { - encoding: 'application/vnd.ipld.car', - }, }, }, }, - ComAtprotoSyncListBlobs: { + AppBskyActorPutPreferences: { lexicon: 1, - id: 'com.atproto.sync.listBlobs', + id: 'app.bsky.actor.putPreferences', defs: { main: { - type: 'query', - description: 'List blob CIDs since some revision.', - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - since: { - type: 'string', - description: 'Optional revision of the repo to list blobs since.', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 1000, - default: 500, - }, - cursor: { - type: 'string', - }, - }, - }, - output: { + type: 'procedure', + description: 'Set the private preferences attached to the account.', + input: { encoding: 'application/json', schema: { type: 'object', - required: ['cids'], + required: ['preferences'], properties: { - cursor: { - type: 'string', - }, - cids: { - type: 'array', - items: { - type: 'string', - format: 'cid', - }, + preferences: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#preferences', }, }, }, @@ -3938,21 +4226,31 @@ export const schemaDict = { }, }, }, - ComAtprotoSyncListRepos: { + AppBskyActorSearchActors: { lexicon: 1, - id: 'com.atproto.sync.listRepos', + id: 'app.bsky.actor.searchActors', defs: { main: { type: 'query', - description: 'List DIDs and root CIDs of hosted repos.', + description: + 'Find actors (profiles) matching search criteria. Does not require auth.', parameters: { type: 'params', properties: { + term: { + type: 'string', + description: "DEPRECATED: use 'q' instead.", + }, + q: { + type: 'string', + description: + 'Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', + }, limit: { type: 'integer', minimum: 1, - maximum: 1000, - default: 500, + maximum: 100, + default: 25, }, cursor: { type: 'string', @@ -3963,83 +4261,63 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['repos'], + required: ['actors'], properties: { cursor: { type: 'string', }, - repos: { + actors: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.sync.listRepos#repo', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, }, }, }, - repo: { - type: 'object', - required: ['did', 'head', 'rev'], - properties: { - did: { - type: 'string', - format: 'did', - }, - head: { - type: 'string', - format: 'cid', - }, - rev: { - type: 'string', - }, - }, - }, }, }, - ComAtprotoSyncNotifyOfUpdate: { + AppBskyActorSearchActorsTypeahead: { lexicon: 1, - id: 'com.atproto.sync.notifyOfUpdate', + id: 'app.bsky.actor.searchActorsTypeahead', defs: { main: { - type: 'procedure', + type: 'query', description: - 'Notify a crawling service of a recent update; often when a long break between updates causes the connection with the crawling service to break.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['hostname'], - properties: { - hostname: { - type: 'string', - description: - 'Hostname of the service that is notifying of update.', - }, + 'Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. Does not require auth.', + parameters: { + type: 'params', + properties: { + term: { + type: 'string', + description: "DEPRECATED: use 'q' instead.", + }, + q: { + type: 'string', + description: 'Search query prefix; not a full query string.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 10, }, }, }, - }, - }, - }, - ComAtprotoSyncRequestCrawl: { - lexicon: 1, - id: 'com.atproto.sync.requestCrawl', - defs: { - main: { - type: 'procedure', - description: 'Request a service to persistently crawl hosted repos.', - input: { + output: { encoding: 'application/json', schema: { type: 'object', - required: ['hostname'], + required: ['actors'], properties: { - hostname: { - type: 'string', - description: - 'Hostname of the service that is requesting to be crawled.', + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewBasic', + }, }, }, }, @@ -4047,550 +4325,571 @@ export const schemaDict = { }, }, }, - ComAtprotoSyncSubscribeRepos: { + AppBskyEmbedExternal: { lexicon: 1, - id: 'com.atproto.sync.subscribeRepos', + id: 'app.bsky.embed.external', defs: { main: { - type: 'subscription', - description: 'Subscribe to repo updates.', - parameters: { - type: 'params', - properties: { - cursor: { - type: 'integer', - description: 'The last known event to backfill from.', - }, - }, - }, - message: { - schema: { - type: 'union', - refs: [ - 'lex:com.atproto.sync.subscribeRepos#commit', - 'lex:com.atproto.sync.subscribeRepos#handle', - 'lex:com.atproto.sync.subscribeRepos#migrate', - 'lex:com.atproto.sync.subscribeRepos#tombstone', - 'lex:com.atproto.sync.subscribeRepos#info', - ], + type: 'object', + description: + "A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post).", + required: ['external'], + properties: { + external: { + type: 'ref', + ref: 'lex:app.bsky.embed.external#external', }, }, - errors: [ - { - name: 'FutureCursor', - }, - { - name: 'ConsumerTooSlow', - }, - ], }, - commit: { + external: { type: 'object', - required: [ - 'seq', - 'rebase', - 'tooBig', - 'repo', - 'commit', - 'rev', - 'since', - 'blocks', - 'ops', - 'blobs', - 'time', - ], - nullable: ['prev', 'since'], + required: ['uri', 'title', 'description'], properties: { - seq: { - type: 'integer', - }, - rebase: { - type: 'boolean', + uri: { + type: 'string', + format: 'uri', }, - tooBig: { - type: 'boolean', + title: { + type: 'string', }, - repo: { + description: { type: 'string', - format: 'did', }, - commit: { - type: 'cid-link', + thumb: { + type: 'blob', + accept: ['image/*'], + maxSize: 1000000, }, - prev: { - type: 'cid-link', + }, + }, + view: { + type: 'object', + required: ['external'], + properties: { + external: { + type: 'ref', + ref: 'lex:app.bsky.embed.external#viewExternal', }, - rev: { + }, + }, + viewExternal: { + type: 'object', + required: ['uri', 'title', 'description'], + properties: { + uri: { type: 'string', - description: 'The rev of the emitted commit.', + format: 'uri', }, - since: { + title: { type: 'string', - description: 'The rev of the last emitted commit from this repo.', }, - blocks: { - type: 'bytes', - description: 'CAR file containing relevant blocks.', - maxLength: 1000000, + description: { + type: 'string', }, - ops: { + thumb: { + type: 'string', + }, + }, + }, + }, + }, + AppBskyEmbedImages: { + lexicon: 1, + id: 'app.bsky.embed.images', + description: 'A set of images embedded in a Bluesky record (eg, a post).', + defs: { + main: { + type: 'object', + required: ['images'], + properties: { + images: { type: 'array', items: { type: 'ref', - ref: 'lex:com.atproto.sync.subscribeRepos#repoOp', + ref: 'lex:app.bsky.embed.images#image', }, - maxLength: 200, + maxLength: 4, }, - blobs: { - type: 'array', - items: { - type: 'cid-link', - }, + }, + }, + image: { + type: 'object', + required: ['image', 'alt'], + properties: { + image: { + type: 'blob', + accept: ['image/*'], + maxSize: 1000000, }, - time: { + alt: { type: 'string', - format: 'datetime', + description: + 'Alt text description of the image, for accessibility.', + }, + aspectRatio: { + type: 'ref', + ref: 'lex:app.bsky.embed.images#aspectRatio', }, }, }, - handle: { + aspectRatio: { type: 'object', - required: ['seq', 'did', 'handle', 'time'], + description: + 'width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.', + required: ['width', 'height'], properties: { - seq: { + width: { type: 'integer', + minimum: 1, }, - did: { + height: { + type: 'integer', + minimum: 1, + }, + }, + }, + view: { + type: 'object', + required: ['images'], + properties: { + images: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.embed.images#viewImage', + }, + maxLength: 4, + }, + }, + }, + viewImage: { + type: 'object', + required: ['thumb', 'fullsize', 'alt'], + properties: { + thumb: { type: 'string', - format: 'did', + description: + 'Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.', }, - handle: { + fullsize: { type: 'string', - format: 'handle', + description: + 'Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.', }, - time: { + alt: { type: 'string', - format: 'datetime', + description: + 'Alt text description of the image, for accessibility.', + }, + aspectRatio: { + type: 'ref', + ref: 'lex:app.bsky.embed.images#aspectRatio', }, }, }, - migrate: { + }, + }, + AppBskyEmbedRecord: { + lexicon: 1, + id: 'app.bsky.embed.record', + description: + 'A representation of a record embedded in a Bluesky record (eg, a post). For example, a quote-post, or sharing a feed generator record.', + defs: { + main: { type: 'object', - required: ['seq', 'did', 'migrateTo', 'time'], - nullable: ['migrateTo'], + required: ['record'], properties: { - seq: { - type: 'integer', - }, - did: { - type: 'string', - format: 'did', - }, - migrateTo: { - type: 'string', + record: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', }, - time: { - type: 'string', - format: 'datetime', + }, + }, + view: { + type: 'object', + required: ['record'], + properties: { + record: { + type: 'union', + refs: [ + 'lex:app.bsky.embed.record#viewRecord', + 'lex:app.bsky.embed.record#viewNotFound', + 'lex:app.bsky.embed.record#viewBlocked', + 'lex:app.bsky.feed.defs#generatorView', + 'lex:app.bsky.graph.defs#listView', + 'lex:app.bsky.labeler.defs#labelerView', + ], }, }, }, - tombstone: { + viewRecord: { type: 'object', - required: ['seq', 'did', 'time'], + required: ['uri', 'cid', 'author', 'value', 'indexedAt'], properties: { - seq: { - type: 'integer', + uri: { + type: 'string', + format: 'at-uri', }, - did: { + cid: { type: 'string', - format: 'did', + format: 'cid', }, - time: { + author: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewBasic', + }, + value: { + type: 'unknown', + description: 'The record data itself.', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + embeds: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:app.bsky.embed.images#view', + 'lex:app.bsky.embed.external#view', + 'lex:app.bsky.embed.record#view', + 'lex:app.bsky.embed.recordWithMedia#view', + ], + }, + }, + indexedAt: { type: 'string', format: 'datetime', }, }, }, - info: { + viewNotFound: { type: 'object', - required: ['name'], + required: ['uri', 'notFound'], properties: { - name: { + uri: { type: 'string', - knownValues: ['OutdatedCursor'], + format: 'at-uri', }, - message: { - type: 'string', + notFound: { + type: 'boolean', + const: true, }, }, }, - repoOp: { + viewBlocked: { type: 'object', - description: - "A repo operation, ie a write of a single record. For creates and updates, CID is the record's CID as of this operation. For deletes, it's null.", - required: ['action', 'path', 'cid'], - nullable: ['cid'], + required: ['uri', 'blocked', 'author'], properties: { - action: { - type: 'string', - knownValues: ['create', 'update', 'delete'], - }, - path: { + uri: { type: 'string', + format: 'at-uri', }, - cid: { - type: 'cid-link', + blocked: { + type: 'boolean', + const: true, }, - }, - }, - }, - }, - ComAtprotoTempCheckSignupQueue: { - lexicon: 1, - id: 'com.atproto.temp.checkSignupQueue', - defs: { - main: { - type: 'query', - description: 'Check accounts location in signup queue.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['activated'], - properties: { - activated: { - type: 'boolean', - }, - placeInQueue: { - type: 'integer', - }, - estimatedTimeMs: { - type: 'integer', - }, - }, + author: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#blockedAuthor', }, }, }, }, }, - ComAtprotoTempFetchLabels: { + AppBskyEmbedRecordWithMedia: { lexicon: 1, - id: 'com.atproto.temp.fetchLabels', + id: 'app.bsky.embed.recordWithMedia', + description: + 'A representation of a record embedded in a Bluesky record (eg, a post), alongside other compatible embeds. For example, a quote post and image, or a quote post and external URL card.', defs: { main: { - type: 'query', - description: - 'Fetch all labels from a labeler created after a certain date.', - parameters: { - type: 'params', - properties: { - since: { - type: 'integer', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 250, - default: 50, - }, + type: 'object', + required: ['record', 'media'], + properties: { + record: { + type: 'ref', + ref: 'lex:app.bsky.embed.record', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['labels'], - properties: { - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, - }, - }, + media: { + type: 'union', + refs: ['lex:app.bsky.embed.images', 'lex:app.bsky.embed.external'], }, }, }, - }, - }, - ComAtprotoTempImportRepo: { - lexicon: 1, - id: 'com.atproto.temp.importRepo', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, + view: { + type: 'object', + required: ['record', 'media'], + properties: { + record: { + type: 'ref', + ref: 'lex:app.bsky.embed.record#view', }, - }, - input: { - encoding: 'application/vnd.ipld.car', - }, - output: { - encoding: 'text/plain', - }, - }, - }, - }, - ComAtprotoTempPushBlob: { - lexicon: 1, - id: 'com.atproto.temp.pushBlob', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, + media: { + type: 'union', + refs: [ + 'lex:app.bsky.embed.images#view', + 'lex:app.bsky.embed.external#view', + ], }, }, - input: { - encoding: '*/*', - }, }, }, }, - ComAtprotoTempRequestPhoneVerification: { + AppBskyFeedDefs: { lexicon: 1, - id: 'com.atproto.temp.requestPhoneVerification', + id: 'app.bsky.feed.defs', defs: { - main: { - type: 'procedure', - description: - 'Request a verification code to be sent to the supplied phone number', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['phoneNumber'], - properties: { - phoneNumber: { - type: 'string', - }, + postView: { + type: 'object', + required: ['uri', 'cid', 'author', 'record', 'indexedAt'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + author: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewBasic', + }, + record: { + type: 'unknown', + }, + embed: { + type: 'union', + refs: [ + 'lex:app.bsky.embed.images#view', + 'lex:app.bsky.embed.external#view', + 'lex:app.bsky.embed.record#view', + 'lex:app.bsky.embed.recordWithMedia#view', + ], + }, + replyCount: { + type: 'integer', + }, + repostCount: { + type: 'integer', + }, + likeCount: { + type: 'integer', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#viewerState', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', }, }, + threadgate: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#threadgateView', + }, }, }, - }, - }, - ComAtprotoTempTransferAccount: { - lexicon: 1, - id: 'com.atproto.temp.transferAccount', - defs: { - main: { - type: 'procedure', - description: 'Transfer an account.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did', 'plcOp'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - plcOp: { - type: 'unknown', - }, - }, + viewerState: { + type: 'object', + description: + "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", + properties: { + repost: { + type: 'string', + format: 'at-uri', + }, + like: { + type: 'string', + format: 'at-uri', + }, + replyDisabled: { + type: 'boolean', }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], - properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, + }, + feedViewPost: { + type: 'object', + required: ['post'], + properties: { + post: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#postView', + }, + reply: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#replyRef', + }, + reason: { + type: 'union', + refs: ['lex:app.bsky.feed.defs#reasonRepost'], }, }, - errors: [ - { - name: 'InvalidHandle', + }, + replyRef: { + type: 'object', + required: ['root', 'parent'], + properties: { + root: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.defs#postView', + 'lex:app.bsky.feed.defs#notFoundPost', + 'lex:app.bsky.feed.defs#blockedPost', + ], }, - { - name: 'InvalidPassword', + parent: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.defs#postView', + 'lex:app.bsky.feed.defs#notFoundPost', + 'lex:app.bsky.feed.defs#blockedPost', + ], }, - { - name: 'InvalidInviteCode', + }, + }, + reasonRepost: { + type: 'object', + required: ['by', 'indexedAt'], + properties: { + by: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileViewBasic', }, - { - name: 'HandleNotAvailable', + indexedAt: { + type: 'string', + format: 'datetime', }, - { - name: 'UnsupportedDomain', + }, + }, + threadViewPost: { + type: 'object', + required: ['post'], + properties: { + post: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#postView', }, - { - name: 'UnresolvableDid', + parent: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.defs#threadViewPost', + 'lex:app.bsky.feed.defs#notFoundPost', + 'lex:app.bsky.feed.defs#blockedPost', + ], }, - { - name: 'IncompatibleDidDoc', + replies: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.defs#threadViewPost', + 'lex:app.bsky.feed.defs#notFoundPost', + 'lex:app.bsky.feed.defs#blockedPost', + ], + }, }, - ], + }, }, - }, - }, - AppBskyActorDefs: { - lexicon: 1, - id: 'app.bsky.actor.defs', - description: 'A reference to an actor in the network.', - defs: { - profileViewBasic: { + notFoundPost: { type: 'object', - required: ['did', 'handle'], + required: ['uri', 'notFound'], properties: { - did: { + uri: { type: 'string', - format: 'did', + format: 'at-uri', }, - handle: { - type: 'string', - format: 'handle', + notFound: { + type: 'boolean', + const: true, }, - displayName: { + }, + }, + blockedPost: { + type: 'object', + required: ['uri', 'blocked', 'author'], + properties: { + uri: { type: 'string', - maxGraphemes: 64, - maxLength: 640, + format: 'at-uri', }, - avatar: { - type: 'string', + blocked: { + type: 'boolean', + const: true, }, - viewer: { + author: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#viewerState', - }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, + ref: 'lex:app.bsky.feed.defs#blockedAuthor', }, }, }, - profileView: { + blockedAuthor: { type: 'object', - required: ['did', 'handle'], + required: ['did'], properties: { did: { type: 'string', format: 'did', }, - handle: { - type: 'string', - format: 'handle', - }, - displayName: { - type: 'string', - maxGraphemes: 64, - maxLength: 640, - }, - description: { - type: 'string', - maxGraphemes: 256, - maxLength: 2560, - }, - avatar: { - type: 'string', - }, - indexedAt: { - type: 'string', - format: 'datetime', - }, viewer: { type: 'ref', ref: 'lex:app.bsky.actor.defs#viewerState', }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, - }, }, }, - profileViewDetailed: { + generatorView: { type: 'object', - required: ['did', 'handle'], + required: ['uri', 'cid', 'did', 'creator', 'displayName', 'indexedAt'], properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, did: { type: 'string', format: 'did', }, - handle: { - type: 'string', - format: 'handle', + creator: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', }, displayName: { type: 'string', - maxGraphemes: 64, - maxLength: 640, }, description: { type: 'string', - maxGraphemes: 256, - maxLength: 2560, + maxGraphemes: 300, + maxLength: 3000, }, - avatar: { - type: 'string', + descriptionFacets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.richtext.facet', + }, }, - banner: { + avatar: { type: 'string', }, - followersCount: { - type: 'integer', - }, - followsCount: { - type: 'integer', - }, - postsCount: { + likeCount: { type: 'integer', - }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#viewerState', + minimum: 0, }, labels: { type: 'array', @@ -4599,277 +4898,199 @@ export const schemaDict = { ref: 'lex:com.atproto.label.defs#label', }, }, - }, - }, - viewerState: { - type: 'object', - properties: { - muted: { - type: 'boolean', - }, - mutedByList: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listViewBasic', - }, - blockedBy: { - type: 'boolean', - }, - blocking: { - type: 'string', - format: 'at-uri', - }, - blockingByList: { + viewer: { type: 'ref', - ref: 'lex:app.bsky.graph.defs#listViewBasic', - }, - following: { - type: 'string', - format: 'at-uri', + ref: 'lex:app.bsky.feed.defs#generatorViewerState', }, - followedBy: { + indexedAt: { type: 'string', - format: 'at-uri', - }, - }, - }, - preferences: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:app.bsky.actor.defs#adultContentPref', - 'lex:app.bsky.actor.defs#contentLabelPref', - 'lex:app.bsky.actor.defs#savedFeedsPref', - 'lex:app.bsky.actor.defs#personalDetailsPref', - 'lex:app.bsky.actor.defs#feedViewPref', - 'lex:app.bsky.actor.defs#threadViewPref', - 'lex:app.bsky.actor.defs#interestsPref', - ], - }, - }, - adultContentPref: { - type: 'object', - required: ['enabled'], - properties: { - enabled: { - type: 'boolean', - default: false, + format: 'datetime', }, }, }, - contentLabelPref: { + generatorViewerState: { type: 'object', - required: ['label', 'visibility'], properties: { - label: { - type: 'string', - }, - visibility: { + like: { type: 'string', - knownValues: ['show', 'warn', 'hide'], + format: 'at-uri', }, }, }, - savedFeedsPref: { + skeletonFeedPost: { type: 'object', - required: ['pinned', 'saved'], + required: ['post'], properties: { - pinned: { - type: 'array', - items: { - type: 'string', - format: 'at-uri', - }, + post: { + type: 'string', + format: 'at-uri', }, - saved: { - type: 'array', - items: { - type: 'string', - format: 'at-uri', - }, + reason: { + type: 'union', + refs: ['lex:app.bsky.feed.defs#skeletonReasonRepost'], }, }, }, - personalDetailsPref: { + skeletonReasonRepost: { type: 'object', + required: ['repost'], properties: { - birthDate: { + repost: { type: 'string', - format: 'datetime', - description: 'The birth date of account owner.', + format: 'at-uri', }, }, }, - feedViewPref: { + threadgateView: { type: 'object', - required: ['feed'], properties: { - feed: { + uri: { type: 'string', - description: - 'The URI of the feed, or an identifier which describes the feed.', - }, - hideReplies: { - type: 'boolean', - description: 'Hide replies in the feed.', - }, - hideRepliesByUnfollowed: { - type: 'boolean', - description: - 'Hide replies in the feed if they are not by followed users.', - }, - hideRepliesByLikeCount: { - type: 'integer', - description: - 'Hide replies in the feed if they do not have this number of likes.', - }, - hideReposts: { - type: 'boolean', - description: 'Hide reposts in the feed.', - }, - hideQuotePosts: { - type: 'boolean', - description: 'Hide quote posts in the feed.', + format: 'at-uri', }, - }, - }, - threadViewPref: { - type: 'object', - properties: { - sort: { + cid: { type: 'string', - description: 'Sorting mode for threads.', - knownValues: ['oldest', 'newest', 'most-likes', 'random'], + format: 'cid', }, - prioritizeFollowedUsers: { - type: 'boolean', - description: 'Show followed users at the top of all replies.', + record: { + type: 'unknown', }, - }, - }, - interestsPref: { - type: 'object', - required: ['tags'], - properties: { - tags: { + lists: { type: 'array', - maxLength: 100, items: { - type: 'string', - maxLength: 640, - maxGraphemes: 64, + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listViewBasic', }, - description: - "A list of tags which describe the account owner's interests gathered during onboarding.", }, }, }, }, }, - AppBskyActorGetPreferences: { + AppBskyFeedDescribeFeedGenerator: { lexicon: 1, - id: 'app.bsky.actor.getPreferences', + id: 'app.bsky.feed.describeFeedGenerator', defs: { main: { type: 'query', - description: 'Get private preferences attached to the account.', - parameters: { - type: 'params', - properties: {}, - }, + description: + 'Get information about a feed generator, including policies and offered feed URIs. Does not require auth; implemented by Feed Generator services (not App View).', output: { encoding: 'application/json', schema: { type: 'object', - required: ['preferences'], + required: ['did', 'feeds'], properties: { - preferences: { + did: { + type: 'string', + format: 'did', + }, + feeds: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.describeFeedGenerator#feed', + }, + }, + links: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#preferences', + ref: 'lex:app.bsky.feed.describeFeedGenerator#links', }, }, }, }, }, - }, - }, - AppBskyActorGetProfile: { - lexicon: 1, - id: 'app.bsky.actor.getProfile', - defs: { - main: { - type: 'query', - description: 'Get detailed profile view of an actor.', - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, + feed: { + type: 'object', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', }, }, - output: { - encoding: 'application/json', - schema: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewDetailed', + }, + links: { + type: 'object', + properties: { + privacyPolicy: { + type: 'string', + }, + termsOfService: { + type: 'string', }, }, }, }, }, - AppBskyActorGetProfiles: { + AppBskyFeedGenerator: { lexicon: 1, - id: 'app.bsky.actor.getProfiles', + id: 'app.bsky.feed.generator', defs: { main: { - type: 'query', - description: 'Get detailed profile views of multiple actors.', - parameters: { - type: 'params', - required: ['actors'], + type: 'record', + description: + 'Record declaring of the existence of a feed generator, and containing metadata about it. The record can exist in any repository.', + key: 'any', + record: { + type: 'object', + required: ['did', 'displayName', 'createdAt'], properties: { - actors: { + did: { + type: 'string', + format: 'did', + }, + displayName: { + type: 'string', + maxGraphemes: 24, + maxLength: 240, + }, + description: { + type: 'string', + maxGraphemes: 300, + maxLength: 3000, + }, + descriptionFacets: { type: 'array', items: { - type: 'string', - format: 'at-identifier', + type: 'ref', + ref: 'lex:app.bsky.richtext.facet', }, - maxLength: 25, }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['profiles'], - properties: { - profiles: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewDetailed', - }, - }, + avatar: { + type: 'blob', + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + labels: { + type: 'union', + description: 'Self-label values', + refs: ['lex:com.atproto.label.defs#selfLabels'], + }, + createdAt: { + type: 'string', + format: 'datetime', }, }, }, }, }, }, - AppBskyActorGetSuggestions: { + AppBskyFeedGetActorFeeds: { lexicon: 1, - id: 'app.bsky.actor.getSuggestions', + id: 'app.bsky.feed.getActorFeeds', defs: { main: { type: 'query', - description: 'Get a list of suggested actors, used for discovery.', + description: + "Get a list of feeds (feed generator records) created by the actor (in the actor's repo).", parameters: { type: 'params', + required: ['actor'], properties: { + actor: { + type: 'string', + format: 'at-identifier', + }, limit: { type: 'integer', minimum: 1, @@ -4885,16 +5106,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['actors'], + required: ['feeds'], properties: { cursor: { type: 'string', }, - actors: { + feeds: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:app.bsky.feed.defs#generatorView', }, }, }, @@ -4903,144 +5124,156 @@ export const schemaDict = { }, }, }, - AppBskyActorProfile: { + AppBskyFeedGetActorLikes: { lexicon: 1, - id: 'app.bsky.actor.profile', + id: 'app.bsky.feed.getActorLikes', defs: { main: { - type: 'record', - description: 'A declaration of a profile.', - key: 'literal:self', - record: { - type: 'object', + type: 'query', + description: + 'Get a list of posts liked by an actor. Does not require auth.', + parameters: { + type: 'params', + required: ['actor'], properties: { - displayName: { - type: 'string', - maxGraphemes: 64, - maxLength: 640, - }, - description: { + actor: { type: 'string', - maxGraphemes: 256, - maxLength: 2560, - }, - avatar: { - type: 'blob', - accept: ['image/png', 'image/jpeg'], - maxSize: 1000000, + format: 'at-identifier', }, - banner: { - type: 'blob', - accept: ['image/png', 'image/jpeg'], - maxSize: 1000000, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, }, - labels: { - type: 'union', - refs: ['lex:com.atproto.label.defs#selfLabels'], + cursor: { + type: 'string', }, }, }, - }, - }, - }, - AppBskyActorPutPreferences: { - lexicon: 1, - id: 'app.bsky.actor.putPreferences', - defs: { - main: { - type: 'procedure', - description: 'Set the private preferences attached to the account.', - input: { + output: { encoding: 'application/json', schema: { type: 'object', - required: ['preferences'], + required: ['feed'], properties: { - preferences: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#preferences', + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, }, }, }, }, + errors: [ + { + name: 'BlockedActor', + }, + { + name: 'BlockedByActor', + }, + ], }, }, }, - AppBskyActorSearchActors: { + AppBskyFeedGetAuthorFeed: { lexicon: 1, - id: 'app.bsky.actor.searchActors', + id: 'app.bsky.feed.getAuthorFeed', defs: { main: { type: 'query', - description: 'Find actors (profiles) matching search criteria.', + description: + "Get a view of an actor's 'author feed' (post and reposts by the author). Does not require auth.", parameters: { type: 'params', + required: ['actor'], properties: { - term: { - type: 'string', - description: "DEPRECATED: use 'q' instead.", - }, - q: { + actor: { type: 'string', - description: - 'Search query string. Syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', + format: 'at-identifier', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 25, + default: 50, }, cursor: { type: 'string', }, + filter: { + type: 'string', + description: + 'Combinations of post/repost types to include in response.', + knownValues: [ + 'posts_with_replies', + 'posts_no_replies', + 'posts_with_media', + 'posts_and_author_threads', + ], + default: 'posts_with_replies', + }, }, }, output: { encoding: 'application/json', schema: { type: 'object', - required: ['actors'], + required: ['feed'], properties: { cursor: { type: 'string', }, - actors: { + feed: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:app.bsky.feed.defs#feedViewPost', }, }, }, }, - }, + }, + errors: [ + { + name: 'BlockedActor', + }, + { + name: 'BlockedByActor', + }, + ], }, }, }, - AppBskyActorSearchActorsTypeahead: { + AppBskyFeedGetFeed: { lexicon: 1, - id: 'app.bsky.actor.searchActorsTypeahead', + id: 'app.bsky.feed.getFeed', defs: { main: { type: 'query', - description: 'Find actor suggestions for a prefix search term.', + description: + "Get a hydrated feed from an actor's selected feed generator. Implemented by App View.", parameters: { type: 'params', + required: ['feed'], properties: { - term: { - type: 'string', - description: "DEPRECATED: use 'q' instead.", - }, - q: { + feed: { type: 'string', - description: 'Search query prefix; not a full query string.', + format: 'at-uri', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 10, + default: 50, + }, + cursor: { + type: 'string', }, }, }, @@ -5048,740 +5281,740 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['actors'], + required: ['feed'], properties: { - actors: { + cursor: { + type: 'string', + }, + feed: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewBasic', + ref: 'lex:app.bsky.feed.defs#feedViewPost', }, }, }, }, }, - }, - }, - }, - AppBskyEmbedExternal: { - lexicon: 1, - id: 'app.bsky.embed.external', - description: - 'A representation of some externally linked content, embedded in another form of content.', - defs: { - main: { - type: 'object', - required: ['external'], - properties: { - external: { - type: 'ref', - ref: 'lex:app.bsky.embed.external#external', - }, - }, - }, - external: { - type: 'object', - required: ['uri', 'title', 'description'], - properties: { - uri: { - type: 'string', - format: 'uri', - }, - title: { - type: 'string', - }, - description: { - type: 'string', - }, - thumb: { - type: 'blob', - accept: ['image/*'], - maxSize: 1000000, - }, - }, - }, - view: { - type: 'object', - required: ['external'], - properties: { - external: { - type: 'ref', - ref: 'lex:app.bsky.embed.external#viewExternal', - }, - }, - }, - viewExternal: { - type: 'object', - required: ['uri', 'title', 'description'], - properties: { - uri: { - type: 'string', - format: 'uri', - }, - title: { - type: 'string', - }, - description: { - type: 'string', - }, - thumb: { - type: 'string', + errors: [ + { + name: 'UnknownFeed', }, - }, + ], }, }, }, - AppBskyEmbedImages: { + AppBskyFeedGetFeedGenerator: { lexicon: 1, - id: 'app.bsky.embed.images', - description: 'A set of images embedded in some other form of content.', + id: 'app.bsky.feed.getFeedGenerator', defs: { main: { - type: 'object', - required: ['images'], - properties: { - images: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.embed.images#image', - }, - maxLength: 4, - }, - }, - }, - image: { - type: 'object', - required: ['image', 'alt'], - properties: { - image: { - type: 'blob', - accept: ['image/*'], - maxSize: 1000000, - }, - alt: { - type: 'string', - }, - aspectRatio: { - type: 'ref', - ref: 'lex:app.bsky.embed.images#aspectRatio', - }, - }, - }, - aspectRatio: { - type: 'object', + type: 'query', description: - 'width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.', - required: ['width', 'height'], - properties: { - width: { - type: 'integer', - minimum: 1, - }, - height: { - type: 'integer', - minimum: 1, - }, - }, - }, - view: { - type: 'object', - required: ['images'], - properties: { - images: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.embed.images#viewImage', - }, - maxLength: 4, - }, - }, - }, - viewImage: { - type: 'object', - required: ['thumb', 'fullsize', 'alt'], - properties: { - thumb: { - type: 'string', - }, - fullsize: { - type: 'string', - }, - alt: { - type: 'string', - }, - aspectRatio: { - type: 'ref', - ref: 'lex:app.bsky.embed.images#aspectRatio', - }, - }, - }, - }, - }, - AppBskyEmbedRecord: { - lexicon: 1, - id: 'app.bsky.embed.record', - description: - 'A representation of a record embedded in another form of content.', - defs: { - main: { - type: 'object', - required: ['record'], - properties: { - record: { - type: 'ref', - ref: 'lex:com.atproto.repo.strongRef', - }, - }, - }, - view: { - type: 'object', - required: ['record'], - properties: { - record: { - type: 'union', - refs: [ - 'lex:app.bsky.embed.record#viewRecord', - 'lex:app.bsky.embed.record#viewNotFound', - 'lex:app.bsky.embed.record#viewBlocked', - 'lex:app.bsky.feed.defs#generatorView', - 'lex:app.bsky.graph.defs#listView', - ], - }, - }, - }, - viewRecord: { - type: 'object', - required: ['uri', 'cid', 'author', 'value', 'indexedAt'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - author: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewBasic', - }, - value: { - type: 'unknown', - }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, - }, - embeds: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:app.bsky.embed.images#view', - 'lex:app.bsky.embed.external#view', - 'lex:app.bsky.embed.record#view', - 'lex:app.bsky.embed.recordWithMedia#view', - ], + 'Get information about a feed generator. Implemented by AppView.', + parameters: { + type: 'params', + required: ['feed'], + properties: { + feed: { + type: 'string', + format: 'at-uri', + description: 'AT-URI of the feed generator record.', }, }, - indexedAt: { - type: 'string', - format: 'datetime', - }, }, - }, - viewNotFound: { - type: 'object', - required: ['uri', 'notFound'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - notFound: { - type: 'boolean', - const: true, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['view', 'isOnline', 'isValid'], + properties: { + view: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#generatorView', + }, + isOnline: { + type: 'boolean', + description: + 'Indicates whether the feed generator service has been online recently, or else seems to be inactive.', + }, + isValid: { + type: 'boolean', + description: + 'Indicates whether the feed generator service is compatible with the record declaration.', + }, + }, }, }, }, - viewBlocked: { - type: 'object', - required: ['uri', 'blocked', 'author'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - blocked: { - type: 'boolean', - const: true, + }, + }, + AppBskyFeedGetFeedGenerators: { + lexicon: 1, + id: 'app.bsky.feed.getFeedGenerators', + defs: { + main: { + type: 'query', + description: 'Get information about a list of feed generators.', + parameters: { + type: 'params', + required: ['feeds'], + properties: { + feeds: { + type: 'array', + items: { + type: 'string', + format: 'at-uri', + }, + }, }, - author: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#blockedAuthor', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feeds'], + properties: { + feeds: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#generatorView', + }, + }, + }, }, }, }, }, }, - AppBskyEmbedRecordWithMedia: { + AppBskyFeedGetFeedSkeleton: { lexicon: 1, - id: 'app.bsky.embed.recordWithMedia', - description: - 'A representation of a record embedded in another form of content, alongside other compatible embeds.', + id: 'app.bsky.feed.getFeedSkeleton', defs: { main: { - type: 'object', - required: ['record', 'media'], - properties: { - record: { - type: 'ref', - ref: 'lex:app.bsky.embed.record', - }, - media: { - type: 'union', - refs: ['lex:app.bsky.embed.images', 'lex:app.bsky.embed.external'], + type: 'query', + description: + 'Get a skeleton of a feed provided by a feed generator. Auth is optional, depending on provider requirements, and provides the DID of the requester. Implemented by Feed Generator Service.', + parameters: { + type: 'params', + required: ['feed'], + properties: { + feed: { + type: 'string', + format: 'at-uri', + description: + 'Reference to feed generator record describing the specific feed being requested.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, }, }, - }, - view: { - type: 'object', - required: ['record', 'media'], - properties: { - record: { - type: 'ref', - ref: 'lex:app.bsky.embed.record#view', - }, - media: { - type: 'union', - refs: [ - 'lex:app.bsky.embed.images#view', - 'lex:app.bsky.embed.external#view', - ], + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#skeletonFeedPost', + }, + }, + }, }, }, + errors: [ + { + name: 'UnknownFeed', + }, + ], }, }, }, - AppBskyFeedDefs: { + AppBskyFeedGetLikes: { lexicon: 1, - id: 'app.bsky.feed.defs', + id: 'app.bsky.feed.getLikes', defs: { - postView: { - type: 'object', - required: ['uri', 'cid', 'author', 'record', 'indexedAt'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - author: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewBasic', - }, - record: { - type: 'unknown', - }, - embed: { - type: 'union', - refs: [ - 'lex:app.bsky.embed.images#view', - 'lex:app.bsky.embed.external#view', - 'lex:app.bsky.embed.record#view', - 'lex:app.bsky.embed.recordWithMedia#view', - ], - }, - replyCount: { - type: 'integer', - }, - repostCount: { - type: 'integer', - }, - likeCount: { - type: 'integer', - }, - indexedAt: { - type: 'string', - format: 'datetime', - }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#viewerState', + main: { + type: 'query', + description: + 'Get like records which reference a subject (by AT-URI and CID).', + parameters: { + type: 'params', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + description: 'AT-URI of the subject (eg, a post record).', + }, + cid: { + type: 'string', + format: 'cid', + description: + 'CID of the subject record (aka, specific version of record), to filter likes.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'likes'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + cursor: { + type: 'string', + }, + likes: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.getLikes#like', + }, + }, }, }, - threadgate: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#threadgateView', - }, }, }, - viewerState: { + like: { type: 'object', + required: ['indexedAt', 'createdAt', 'actor'], properties: { - repost: { + indexedAt: { type: 'string', - format: 'at-uri', + format: 'datetime', }, - like: { + createdAt: { type: 'string', - format: 'at-uri', - }, - replyDisabled: { - type: 'boolean', - }, - }, - }, - feedViewPost: { - type: 'object', - required: ['post'], - properties: { - post: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#postView', + format: 'datetime', }, - reply: { + actor: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#replyRef', - }, - reason: { - type: 'union', - refs: ['lex:app.bsky.feed.defs#reasonRepost'], + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, - replyRef: { - type: 'object', - required: ['root', 'parent'], - properties: { - root: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.defs#postView', - 'lex:app.bsky.feed.defs#notFoundPost', - 'lex:app.bsky.feed.defs#blockedPost', - ], - }, - parent: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.defs#postView', - 'lex:app.bsky.feed.defs#notFoundPost', - 'lex:app.bsky.feed.defs#blockedPost', - ], + }, + }, + AppBskyFeedGetListFeed: { + lexicon: 1, + id: 'app.bsky.feed.getListFeed', + defs: { + main: { + type: 'query', + description: + 'Get a feed of recent posts from a list (posts and reposts from any actors on the list). Does not require auth.', + parameters: { + type: 'params', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', + description: 'Reference (AT-URI) to the list record.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, }, }, - }, - reasonRepost: { - type: 'object', - required: ['by', 'indexedAt'], - properties: { - by: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileViewBasic', - }, - indexedAt: { - type: 'string', - format: 'datetime', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, + }, + }, }, }, - }, - threadViewPost: { - type: 'object', - required: ['post'], - properties: { - post: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#postView', - }, - parent: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.defs#threadViewPost', - 'lex:app.bsky.feed.defs#notFoundPost', - 'lex:app.bsky.feed.defs#blockedPost', - ], + errors: [ + { + name: 'UnknownList', }, - replies: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.defs#threadViewPost', - 'lex:app.bsky.feed.defs#notFoundPost', - 'lex:app.bsky.feed.defs#blockedPost', - ], + ], + }, + }, + }, + AppBskyFeedGetPostThread: { + lexicon: 1, + id: 'app.bsky.feed.getPostThread', + defs: { + main: { + type: 'query', + description: + 'Get posts in a thread. Does not require auth, but additional metadata and filtering will be applied for authed requests.', + parameters: { + type: 'params', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + description: 'Reference (AT-URI) to post record.', + }, + depth: { + type: 'integer', + description: + 'How many levels of reply depth should be included in response.', + default: 6, + minimum: 0, + maximum: 1000, + }, + parentHeight: { + type: 'integer', + description: + 'How many levels of parent (and grandparent, etc) post to include.', + default: 80, + minimum: 0, + maximum: 1000, }, }, }, - }, - notFoundPost: { - type: 'object', - required: ['uri', 'notFound'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - notFound: { - type: 'boolean', - const: true, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['thread'], + properties: { + thread: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.defs#threadViewPost', + 'lex:app.bsky.feed.defs#notFoundPost', + 'lex:app.bsky.feed.defs#blockedPost', + ], + }, + }, }, }, - }, - blockedPost: { - type: 'object', - required: ['uri', 'blocked', 'author'], - properties: { - uri: { - type: 'string', - format: 'at-uri', + errors: [ + { + name: 'NotFound', }, - blocked: { - type: 'boolean', - const: true, + ], + }, + }, + }, + AppBskyFeedGetPosts: { + lexicon: 1, + id: 'app.bsky.feed.getPosts', + defs: { + main: { + type: 'query', + description: + "Gets post views for a specified list of posts (by AT-URI). This is sometimes referred to as 'hydrating' a 'feed skeleton'.", + parameters: { + type: 'params', + required: ['uris'], + properties: { + uris: { + type: 'array', + description: 'List of post AT-URIs to return hydrated views for.', + items: { + type: 'string', + format: 'at-uri', + }, + maxLength: 25, + }, }, - author: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#blockedAuthor', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['posts'], + properties: { + posts: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#postView', + }, + }, + }, }, }, }, - blockedAuthor: { - type: 'object', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', + }, + }, + AppBskyFeedGetRepostedBy: { + lexicon: 1, + id: 'app.bsky.feed.getRepostedBy', + defs: { + main: { + type: 'query', + description: 'Get a list of reposts for a given post.', + parameters: { + type: 'params', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + description: 'Reference (AT-URI) of post record', + }, + cid: { + type: 'string', + format: 'cid', + description: + 'If supplied, filters to reposts of specific version (by CID) of the post record.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#viewerState', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'repostedBy'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + cursor: { + type: 'string', + }, + repostedBy: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + }, + }, }, }, }, - generatorView: { - type: 'object', - required: ['uri', 'cid', 'did', 'creator', 'displayName', 'indexedAt'], - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - did: { - type: 'string', - format: 'did', - }, - creator: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - displayName: { - type: 'string', - }, - description: { - type: 'string', - maxGraphemes: 300, - maxLength: 3000, - }, - descriptionFacets: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet', + }, + }, + AppBskyFeedGetSuggestedFeeds: { + lexicon: 1, + id: 'app.bsky.feed.getSuggestedFeeds', + defs: { + main: { + type: 'query', + description: + 'Get a list of suggested feeds (feed generators) for the requesting account.', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', }, - }, - avatar: { - type: 'string', - }, - likeCount: { - type: 'integer', - minimum: 0, - }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorViewerState', - }, - indexedAt: { - type: 'string', - format: 'datetime', }, }, - }, - generatorViewerState: { - type: 'object', - properties: { - like: { - type: 'string', - format: 'at-uri', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feeds'], + properties: { + cursor: { + type: 'string', + }, + feeds: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#generatorView', + }, + }, + }, }, }, }, - skeletonFeedPost: { - type: 'object', - required: ['post'], - properties: { - post: { - type: 'string', - format: 'at-uri', - }, - reason: { - type: 'union', - refs: ['lex:app.bsky.feed.defs#skeletonReasonRepost'], + }, + }, + AppBskyFeedGetTimeline: { + lexicon: 1, + id: 'app.bsky.feed.getTimeline', + defs: { + main: { + type: 'query', + description: + "Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed.", + parameters: { + type: 'params', + properties: { + algorithm: { + type: 'string', + description: + "Variant 'algorithm' for timeline. Implementation-specific. NOTE: most feed flexibility has been moved to feed generator mechanism.", + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, }, }, - }, - skeletonReasonRepost: { - type: 'object', - required: ['repost'], - properties: { - repost: { - type: 'string', - format: 'at-uri', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, + }, + }, }, }, }, - threadgateView: { - type: 'object', - properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - record: { - type: 'unknown', - }, - lists: { - type: 'array', - items: { + }, + }, + AppBskyFeedLike: { + lexicon: 1, + id: 'app.bsky.feed.like', + defs: { + main: { + type: 'record', + description: "Record declaring a 'like' of a piece of subject content.", + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { type: 'ref', - ref: 'lex:app.bsky.graph.defs#listViewBasic', + ref: 'lex:com.atproto.repo.strongRef', + }, + createdAt: { + type: 'string', + format: 'datetime', }, }, }, }, }, }, - AppBskyFeedDescribeFeedGenerator: { + AppBskyFeedPost: { lexicon: 1, - id: 'app.bsky.feed.describeFeedGenerator', + id: 'app.bsky.feed.post', defs: { main: { - type: 'query', - description: - 'Get information about a feed generator, including policies and offered feed URIs.', - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['did', 'feeds'], - properties: { - did: { - type: 'string', - format: 'did', - }, - feeds: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.feed.describeFeedGenerator#feed', - }, + type: 'record', + description: 'Record containing a Bluesky post.', + key: 'tid', + record: { + type: 'object', + required: ['text', 'createdAt'], + properties: { + text: { + type: 'string', + maxLength: 3000, + maxGraphemes: 300, + description: + 'The primary post content. May be an empty string, if there are embeds.', + }, + entities: { + type: 'array', + description: 'DEPRECATED: replaced by app.bsky.richtext.facet.', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.post#entity', }, - links: { + }, + facets: { + type: 'array', + description: + 'Annotations of text (mentions, URLs, hashtags, etc)', + items: { type: 'ref', - ref: 'lex:app.bsky.feed.describeFeedGenerator#links', + ref: 'lex:app.bsky.richtext.facet', + }, + }, + reply: { + type: 'ref', + ref: 'lex:app.bsky.feed.post#replyRef', + }, + embed: { + type: 'union', + refs: [ + 'lex:app.bsky.embed.images', + 'lex:app.bsky.embed.external', + 'lex:app.bsky.embed.record', + 'lex:app.bsky.embed.recordWithMedia', + ], + }, + langs: { + type: 'array', + description: + 'Indicates human language of post primary text content.', + maxLength: 3, + items: { + type: 'string', + format: 'language', + }, + }, + labels: { + type: 'union', + description: + 'Self-label values for this post. Effectively content warnings.', + refs: ['lex:com.atproto.label.defs#selfLabels'], + }, + tags: { + type: 'array', + description: + 'Additional hashtags, in addition to any included in post text and facets.', + maxLength: 8, + items: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, }, }, + createdAt: { + type: 'string', + format: 'datetime', + description: + 'Client-declared timestamp when this post was originally created.', + }, }, }, }, - feed: { + replyRef: { type: 'object', - required: ['uri'], + required: ['root', 'parent'], properties: { - uri: { - type: 'string', - format: 'at-uri', + root: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, + parent: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', }, }, }, - links: { + entity: { type: 'object', + description: 'Deprecated: use facets instead.', + required: ['index', 'type', 'value'], properties: { - privacyPolicy: { + index: { + type: 'ref', + ref: 'lex:app.bsky.feed.post#textSlice', + }, + type: { type: 'string', + description: "Expected values are 'mention' and 'link'.", }, - termsOfService: { + value: { type: 'string', }, }, }, + textSlice: { + type: 'object', + description: + 'Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.', + required: ['start', 'end'], + properties: { + start: { + type: 'integer', + minimum: 0, + }, + end: { + type: 'integer', + minimum: 0, + }, + }, + }, }, }, - AppBskyFeedGenerator: { + AppBskyFeedRepost: { lexicon: 1, - id: 'app.bsky.feed.generator', + id: 'app.bsky.feed.repost', defs: { main: { + description: + "Record representing a 'repost' of an existing Bluesky post.", type: 'record', - description: 'A declaration of the existence of a feed generator.', - key: 'any', + key: 'tid', record: { type: 'object', - required: ['did', 'displayName', 'createdAt'], + required: ['subject', 'createdAt'], properties: { - did: { - type: 'string', - format: 'did', - }, - displayName: { - type: 'string', - maxGraphemes: 24, - maxLength: 240, - }, - description: { - type: 'string', - maxGraphemes: 300, - maxLength: 3000, - }, - descriptionFacets: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet', - }, - }, - avatar: { - type: 'blob', - accept: ['image/png', 'image/jpeg'], - maxSize: 1000000, - }, - labels: { - type: 'union', - refs: ['lex:com.atproto.label.defs#selfLabels'], + subject: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', }, createdAt: { type: 'string', @@ -5792,29 +6025,33 @@ export const schemaDict = { }, }, }, - AppBskyFeedGetActorFeeds: { + AppBskyFeedSearchPosts: { lexicon: 1, - id: 'app.bsky.feed.getActorFeeds', + id: 'app.bsky.feed.searchPosts', defs: { main: { type: 'query', - description: 'Get a list of feeds created by the actor.', + description: + 'Find posts matching search criteria, returning views of those posts.', parameters: { type: 'params', - required: ['actor'], + required: ['q'], properties: { - actor: { + q: { type: 'string', - format: 'at-identifier', + description: + 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 50, + default: 25, }, cursor: { type: 'string', + description: + 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', }, }, }, @@ -5822,214 +6059,352 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feeds'], + required: ['posts'], properties: { cursor: { type: 'string', }, - feeds: { + hitsTotal: { + type: 'integer', + description: + 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', + }, + posts: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorView', + ref: 'lex:app.bsky.feed.defs#postView', }, }, }, }, }, + errors: [ + { + name: 'BadQueryString', + }, + ], + }, + }, + }, + AppBskyFeedThreadgate: { + lexicon: 1, + id: 'app.bsky.feed.threadgate', + defs: { + main: { + type: 'record', + key: 'tid', + description: + "Record defining interaction gating rules for a thread (aka, reply controls). The record key (rkey) of the threadgate record must match the record key of the thread's root post, and that record must be in the same repository..", + record: { + type: 'object', + required: ['post', 'createdAt'], + properties: { + post: { + type: 'string', + format: 'at-uri', + description: 'Reference (AT-URI) to the post record.', + }, + allow: { + type: 'array', + maxLength: 5, + items: { + type: 'union', + refs: [ + 'lex:app.bsky.feed.threadgate#mentionRule', + 'lex:app.bsky.feed.threadgate#followingRule', + 'lex:app.bsky.feed.threadgate#listRule', + ], + }, + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + mentionRule: { + type: 'object', + description: 'Allow replies from actors mentioned in your post.', + properties: {}, + }, + followingRule: { + type: 'object', + description: 'Allow replies from actors you follow.', + properties: {}, + }, + listRule: { + type: 'object', + description: 'Allow replies from actors on a list.', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', + }, + }, }, }, }, - AppBskyFeedGetActorLikes: { + AppBskyGraphBlock: { lexicon: 1, - id: 'app.bsky.feed.getActorLikes', + id: 'app.bsky.graph.block', defs: { main: { - type: 'query', - description: 'Get a list of posts liked by an actor.', - parameters: { - type: 'params', - required: ['actor'], + type: 'record', + description: + "Record declaring a 'block' relationship against another account. NOTE: blocks are public in Bluesky; see blog posts for details.", + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], properties: { - actor: { + subject: { type: 'string', - format: 'at-identifier', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, + format: 'did', + description: 'DID of the account to be blocked.', }, - cursor: { + createdAt: { type: 'string', + format: 'datetime', }, }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['feed'], - properties: { - cursor: { - type: 'string', - }, - feed: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#feedViewPost', - }, - }, - }, - }, - }, - errors: [ - { - name: 'BlockedActor', - }, - { - name: 'BlockedByActor', - }, - ], }, }, }, - AppBskyFeedGetAuthorFeed: { + AppBskyGraphDefs: { lexicon: 1, - id: 'app.bsky.feed.getAuthorFeed', + id: 'app.bsky.graph.defs', defs: { - main: { - type: 'query', - description: "Get a view of an actor's feed.", - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, + listViewBasic: { + type: 'object', + required: ['uri', 'cid', 'name', 'purpose'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + name: { + type: 'string', + maxLength: 64, + minLength: 1, + }, + purpose: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listPurpose', + }, + avatar: { + type: 'string', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', }, - cursor: { - type: 'string', + }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listViewerState', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + listView: { + type: 'object', + required: ['uri', 'cid', 'creator', 'name', 'purpose', 'indexedAt'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + creator: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + name: { + type: 'string', + maxLength: 64, + minLength: 1, + }, + purpose: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listPurpose', + }, + description: { + type: 'string', + maxGraphemes: 300, + maxLength: 3000, + }, + descriptionFacets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.richtext.facet', }, - filter: { - type: 'string', - knownValues: [ - 'posts_with_replies', - 'posts_no_replies', - 'posts_with_media', - 'posts_and_author_threads', - ], - default: 'posts_with_replies', + }, + avatar: { + type: 'string', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', }, }, + viewer: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listViewerState', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['feed'], - properties: { - cursor: { - type: 'string', - }, - feed: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#feedViewPost', - }, - }, - }, + }, + listItemView: { + type: 'object', + required: ['uri', 'subject'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + subject: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + }, + }, + listPurpose: { + type: 'string', + knownValues: [ + 'app.bsky.graph.defs#modlist', + 'app.bsky.graph.defs#curatelist', + ], + }, + modlist: { + type: 'token', + description: + 'A list of actors to apply an aggregate moderation action (mute/block) on.', + }, + curatelist: { + type: 'token', + description: + 'A list of actors used for curation purposes such as list feeds or interaction gating.', + }, + listViewerState: { + type: 'object', + properties: { + muted: { + type: 'boolean', + }, + blocked: { + type: 'string', + format: 'at-uri', + }, + }, + }, + notFoundActor: { + type: 'object', + description: 'indicates that a handle or DID could not be resolved', + required: ['actor', 'notFound'], + properties: { + actor: { + type: 'string', + format: 'at-identifier', + }, + notFound: { + type: 'boolean', + const: true, }, }, - errors: [ - { - name: 'BlockedActor', + }, + relationship: { + type: 'object', + description: + 'lists the bi-directional graph relationships between one actor (not indicated in the object), and the target actors (the DID included in the object)', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', }, - { - name: 'BlockedByActor', + following: { + type: 'string', + format: 'at-uri', + description: + 'if the actor follows this DID, this is the AT-URI of the follow record', }, - ], + followedBy: { + type: 'string', + format: 'at-uri', + description: + 'if the actor is followed by this DID, contains the AT-URI of the follow record', + }, + }, }, }, }, - AppBskyFeedGetFeed: { + AppBskyGraphFollow: { lexicon: 1, - id: 'app.bsky.feed.getFeed', + id: 'app.bsky.graph.follow', defs: { main: { - type: 'query', + type: 'record', description: - "Get a hydrated feed from an actor's selected feed generator.", - parameters: { - type: 'params', - required: ['feed'], + "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], properties: { - feed: { + subject: { type: 'string', - format: 'at-uri', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, + format: 'did', }, - cursor: { + createdAt: { type: 'string', + format: 'datetime', }, }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['feed'], - properties: { - cursor: { - type: 'string', - }, - feed: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#feedViewPost', - }, - }, - }, - }, - }, - errors: [ - { - name: 'UnknownFeed', - }, - ], }, }, }, - AppBskyFeedGetFeedGenerator: { + AppBskyGraphGetBlocks: { lexicon: 1, - id: 'app.bsky.feed.getFeedGenerator', + id: 'app.bsky.graph.getBlocks', defs: { main: { type: 'query', - description: 'Get information about a feed generator.', + description: + 'Enumerates which accounts the requesting account is currently blocking. Requires auth.', parameters: { type: 'params', - required: ['feed'], properties: { - feed: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { type: 'string', - format: 'at-uri', }, }, }, @@ -6037,55 +6412,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['view', 'isOnline', 'isValid'], + required: ['blocks'], properties: { - view: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorView', - }, - isOnline: { - type: 'boolean', - }, - isValid: { - type: 'boolean', - }, - }, - }, - }, - }, - }, - }, - AppBskyFeedGetFeedGenerators: { - lexicon: 1, - id: 'app.bsky.feed.getFeedGenerators', - defs: { - main: { - type: 'query', - description: 'Get information about a list of feed generators.', - parameters: { - type: 'params', - required: ['feeds'], - properties: { - feeds: { - type: 'array', - items: { + cursor: { type: 'string', - format: 'at-uri', }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['feeds'], - properties: { - feeds: { + blocks: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorView', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, @@ -6094,20 +6430,21 @@ export const schemaDict = { }, }, }, - AppBskyFeedGetFeedSkeleton: { + AppBskyGraphGetFollowers: { lexicon: 1, - id: 'app.bsky.feed.getFeedSkeleton', + id: 'app.bsky.graph.getFollowers', defs: { main: { type: 'query', - description: 'Get a skeleton of a feed provided by a feed generator.', + description: + 'Enumerates accounts which follow a specified account (actor).', parameters: { type: 'params', - required: ['feed'], + required: ['actor'], properties: { - feed: { + actor: { type: 'string', - format: 'at-uri', + format: 'at-identifier', }, limit: { type: 'integer', @@ -6124,47 +6461,43 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feed'], + required: ['subject', 'followers'], properties: { + subject: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, cursor: { type: 'string', }, - feed: { + followers: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#skeletonFeedPost', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, }, }, - errors: [ - { - name: 'UnknownFeed', - }, - ], }, }, }, - AppBskyFeedGetLikes: { + AppBskyGraphGetFollows: { lexicon: 1, - id: 'app.bsky.feed.getLikes', + id: 'app.bsky.graph.getFollows', defs: { main: { type: 'query', - description: 'Get the list of likes.', + description: + 'Enumerates accounts which a specified account (actor) follows.', parameters: { type: 'params', - required: ['uri'], + required: ['actor'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { + actor: { type: 'string', - format: 'cid', + format: 'at-identifier', }, limit: { type: 'integer', @@ -6181,57 +6514,36 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['uri', 'likes'], + required: ['subject', 'follows'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', + subject: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', }, cursor: { type: 'string', }, - likes: { + follows: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.getLikes#like', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, }, }, }, - like: { - type: 'object', - required: ['indexedAt', 'createdAt', 'actor'], - properties: { - indexedAt: { - type: 'string', - format: 'datetime', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, - actor: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - }, - }, }, }, - AppBskyFeedGetListFeed: { + AppBskyGraphGetList: { lexicon: 1, - id: 'app.bsky.feed.getListFeed', + id: 'app.bsky.graph.getList', defs: { main: { type: 'query', - description: 'Get a view of a recent posts from actors in a list.', + description: + "Gets a 'view' (with additional context) of a specified list.", parameters: { type: 'params', required: ['list'], @@ -6239,6 +6551,7 @@ export const schemaDict = { list: { type: 'string', format: 'at-uri', + description: 'Reference (AT-URI) of the list record to hydrate.', }, limit: { type: 'integer', @@ -6255,55 +6568,47 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feed'], + required: ['list', 'items'], properties: { cursor: { type: 'string', }, - feed: { + list: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listView', + }, + items: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#feedViewPost', + ref: 'lex:app.bsky.graph.defs#listItemView', }, }, }, }, }, - errors: [ - { - name: 'UnknownList', - }, - ], }, }, }, - AppBskyFeedGetPostThread: { + AppBskyGraphGetListBlocks: { lexicon: 1, - id: 'app.bsky.feed.getPostThread', + id: 'app.bsky.graph.getListBlocks', defs: { main: { type: 'query', - description: 'Get posts in a thread.', + description: + 'Get mod lists that the requesting account (actor) is blocking. Requires auth.', parameters: { type: 'params', - required: ['uri'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - depth: { + limit: { type: 'integer', - default: 6, - minimum: 0, - maximum: 1000, + minimum: 1, + maximum: 100, + default: 50, }, - parentHeight: { - type: 'integer', - default: 80, - minimum: 0, - maximum: 1000, + cursor: { + type: 'string', }, }, }, @@ -6311,45 +6616,43 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['thread'], + required: ['lists'], properties: { - thread: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.defs#threadViewPost', - 'lex:app.bsky.feed.defs#notFoundPost', - 'lex:app.bsky.feed.defs#blockedPost', - ], + cursor: { + type: 'string', + }, + lists: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listView', + }, }, }, }, }, - errors: [ - { - name: 'NotFound', - }, - ], }, }, }, - AppBskyFeedGetPosts: { + AppBskyGraphGetListMutes: { lexicon: 1, - id: 'app.bsky.feed.getPosts', + id: 'app.bsky.graph.getListMutes', defs: { main: { type: 'query', - description: "Get a view of an actor's feed.", + description: + 'Enumerates mod lists that the requesting account (actor) currently has muted. Requires auth.', parameters: { type: 'params', - required: ['uris'], properties: { - uris: { - type: 'array', - items: { - type: 'string', - format: 'at-uri', - }, - maxLength: 25, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', }, }, }, @@ -6357,13 +6660,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['posts'], + required: ['lists'], properties: { - posts: { + cursor: { + type: 'string', + }, + lists: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#postView', + ref: 'lex:app.bsky.graph.defs#listView', }, }, }, @@ -6372,24 +6678,22 @@ export const schemaDict = { }, }, }, - AppBskyFeedGetRepostedBy: { + AppBskyGraphGetLists: { lexicon: 1, - id: 'app.bsky.feed.getRepostedBy', + id: 'app.bsky.graph.getLists', defs: { main: { type: 'query', - description: 'Get a list of reposts.', + description: + 'Enumerates the lists created by a specified account (actor).', parameters: { type: 'params', - required: ['uri'], + required: ['actor'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { + actor: { type: 'string', - format: 'cid', + format: 'at-identifier', + description: 'The account (actor) to enumerate lists from.', }, limit: { type: 'integer', @@ -6406,24 +6710,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['uri', 'repostedBy'], + required: ['lists'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, cursor: { type: 'string', }, - repostedBy: { + lists: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:app.bsky.graph.defs#listView', }, }, }, @@ -6432,13 +6728,14 @@ export const schemaDict = { }, }, }, - AppBskyFeedGetSuggestedFeeds: { + AppBskyGraphGetMutes: { lexicon: 1, - id: 'app.bsky.feed.getSuggestedFeeds', + id: 'app.bsky.graph.getMutes', defs: { main: { type: 'query', - description: 'Get a list of suggested feeds for the viewer.', + description: + 'Enumerates accounts that the requesting account (actor) currently has muted. Requires auth.', parameters: { type: 'params', properties: { @@ -6457,16 +6754,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feeds'], + required: ['mutes'], properties: { cursor: { type: 'string', }, - feeds: { + mutes: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorView', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, @@ -6475,27 +6772,32 @@ export const schemaDict = { }, }, }, - AppBskyFeedGetTimeline: { + AppBskyGraphGetRelationships: { lexicon: 1, - id: 'app.bsky.feed.getTimeline', + id: 'app.bsky.graph.getRelationships', defs: { main: { type: 'query', - description: "Get a view of the actor's home timeline.", + description: + 'Enumerates public relationships between one account, and a list of other accounts. Does not require auth.', parameters: { type: 'params', + required: ['actor'], properties: { - algorithm: { + actor: { type: 'string', + format: 'at-identifier', + description: 'Primary account requesting relationships for.', }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', + others: { + type: 'array', + description: + "List of 'other' accounts to be related back to the primary.", + maxLength: 30, + items: { + type: 'string', + format: 'at-identifier', + }, }, }, }, @@ -6503,16 +6805,64 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feed'], + required: ['relationships'], properties: { - cursor: { + actor: { type: 'string', + format: 'did', }, - feed: { + relationships: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:app.bsky.graph.defs#relationship', + 'lex:app.bsky.graph.defs#notFoundActor', + ], + }, + }, + }, + }, + }, + errors: [ + { + name: 'ActorNotFound', + description: + 'the primary actor at-identifier could not be resolved', + }, + ], + }, + }, + }, + AppBskyGraphGetSuggestedFollowsByActor: { + lexicon: 1, + id: 'app.bsky.graph.getSuggestedFollowsByActor', + defs: { + main: { + type: 'query', + description: + 'Enumerates follows similar to a given account (actor). Expected use is to recommend additional accounts immediately after following one account.', + parameters: { + type: 'params', + required: ['actor'], + properties: { + actor: { + type: 'string', + format: 'at-identifier', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['suggestions'], + properties: { + suggestions: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#feedViewPost', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, }, @@ -6521,21 +6871,111 @@ export const schemaDict = { }, }, }, - AppBskyFeedLike: { + AppBskyGraphList: { + lexicon: 1, + id: 'app.bsky.graph.list', + defs: { + main: { + type: 'record', + description: + 'Record representing a list of accounts (actors). Scope includes both moderation-oriented lists and curration-oriented lists.', + key: 'tid', + record: { + type: 'object', + required: ['name', 'purpose', 'createdAt'], + properties: { + purpose: { + type: 'ref', + description: + 'Defines the purpose of the list (aka, moderation-oriented or curration-oriented)', + ref: 'lex:app.bsky.graph.defs#listPurpose', + }, + name: { + type: 'string', + maxLength: 64, + minLength: 1, + description: 'Display name for list; can not be empty.', + }, + description: { + type: 'string', + maxGraphemes: 300, + maxLength: 3000, + }, + descriptionFacets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.richtext.facet', + }, + }, + avatar: { + type: 'blob', + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + labels: { + type: 'union', + refs: ['lex:com.atproto.label.defs#selfLabels'], + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, + AppBskyGraphListblock: { + lexicon: 1, + id: 'app.bsky.graph.listblock', + defs: { + main: { + type: 'record', + description: + 'Record representing a block relationship against an entire an entire list of accounts (actors).', + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + description: 'Reference (AT-URI) to the mod list record.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, + AppBskyGraphListitem: { lexicon: 1, - id: 'app.bsky.feed.like', + id: 'app.bsky.graph.listitem', defs: { main: { type: 'record', - description: 'A declaration of a like.', + description: + "Record representing an account's inclusion on a specific list. The AppView will ignore duplicate listitem records.", key: 'tid', record: { type: 'object', - required: ['subject', 'createdAt'], + required: ['subject', 'list', 'createdAt'], properties: { subject: { - type: 'ref', - ref: 'lex:com.atproto.repo.strongRef', + type: 'string', + format: 'did', + description: 'The account which is included on the list.', + }, + list: { + type: 'string', + format: 'at-uri', + description: + 'Reference (AT-URI) to the list record (app.bsky.graph.list).', }, createdAt: { type: 'string', @@ -6546,181 +6986,238 @@ export const schemaDict = { }, }, }, - AppBskyFeedPost: { + AppBskyGraphMuteActor: { lexicon: 1, - id: 'app.bsky.feed.post', + id: 'app.bsky.graph.muteActor', defs: { main: { - type: 'record', - description: 'A declaration of a post.', - key: 'tid', - record: { - type: 'object', - required: ['text', 'createdAt'], - properties: { - text: { - type: 'string', - maxLength: 3000, - maxGraphemes: 300, - }, - entities: { - type: 'array', - description: 'Deprecated: replaced by app.bsky.richtext.facet.', - items: { - type: 'ref', - ref: 'lex:app.bsky.feed.post#entity', + type: 'procedure', + description: + 'Creates a mute relationship for the specified account. Mutes are private in Bluesky. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actor'], + properties: { + actor: { + type: 'string', + format: 'at-identifier', }, }, - facets: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet', + }, + }, + }, + }, + }, + AppBskyGraphMuteActorList: { + lexicon: 1, + id: 'app.bsky.graph.muteActorList', + defs: { + main: { + type: 'procedure', + description: + 'Creates a mute relationship for the specified list of accounts. Mutes are private in Bluesky. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', }, }, - reply: { - type: 'ref', - ref: 'lex:app.bsky.feed.post#replyRef', - }, - embed: { - type: 'union', - refs: [ - 'lex:app.bsky.embed.images', - 'lex:app.bsky.embed.external', - 'lex:app.bsky.embed.record', - 'lex:app.bsky.embed.recordWithMedia', - ], - }, - langs: { - type: 'array', - maxLength: 3, - items: { + }, + }, + }, + }, + }, + AppBskyGraphUnmuteActor: { + lexicon: 1, + id: 'app.bsky.graph.unmuteActor', + defs: { + main: { + type: 'procedure', + description: 'Unmutes the specified account. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actor'], + properties: { + actor: { type: 'string', - format: 'language', + format: 'at-identifier', }, }, - labels: { - type: 'union', - refs: ['lex:com.atproto.label.defs#selfLabels'], - }, - tags: { - type: 'array', - maxLength: 8, - items: { + }, + }, + }, + }, + }, + AppBskyGraphUnmuteActorList: { + lexicon: 1, + id: 'app.bsky.graph.unmuteActorList', + defs: { + main: { + type: 'procedure', + description: 'Unmutes the specified list of accounts. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['list'], + properties: { + list: { type: 'string', - maxLength: 640, - maxGraphemes: 64, + format: 'at-uri', }, - description: 'Additional non-inline tags describing this post.', - }, - createdAt: { - type: 'string', - format: 'datetime', }, }, }, }, - replyRef: { + }, + }, + AppBskyLabelerDefs: { + lexicon: 1, + id: 'app.bsky.labeler.defs', + defs: { + labelerView: { type: 'object', - required: ['root', 'parent'], + required: ['uri', 'cid', 'creator', 'indexedAt'], properties: { - root: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + creator: { type: 'ref', - ref: 'lex:com.atproto.repo.strongRef', + ref: 'lex:app.bsky.actor.defs#profileView', }, - parent: { + likeCount: { + type: 'integer', + minimum: 0, + }, + viewer: { type: 'ref', - ref: 'lex:com.atproto.repo.strongRef', + 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', + }, }, }, }, - entity: { + labelerViewDetailed: { type: 'object', - description: 'Deprecated: use facets instead.', - required: ['index', 'type', 'value'], + required: ['uri', 'cid', 'creator', 'policies', 'indexedAt'], properties: { - index: { - type: 'ref', - ref: 'lex:app.bsky.feed.post#textSlice', + uri: { + type: 'string', + format: 'at-uri', }, - type: { + cid: { type: 'string', - description: "Expected values are 'mention' and 'link'.", + format: 'cid', }, - value: { + 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', + }, }, }, }, - textSlice: { + labelerViewerState: { type: 'object', - description: - 'Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.', - required: ['start', 'end'], properties: { - start: { - type: 'integer', - minimum: 0, - }, - end: { - type: 'integer', - minimum: 0, + like: { + type: 'string', + format: 'at-uri', }, }, }, - }, - }, - AppBskyFeedRepost: { - lexicon: 1, - id: 'app.bsky.feed.repost', - defs: { - main: { - description: 'A declaration of a repost.', - type: 'record', - key: 'tid', - record: { - type: 'object', - required: ['subject', 'createdAt'], - properties: { - subject: { + 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.repo.strongRef', + ref: 'lex:com.atproto.label.defs#labelValue', }, - createdAt: { - type: 'string', - format: 'datetime', + }, + 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', }, }, }, }, }, }, - AppBskyFeedSearchPosts: { + AppBskyLabelerGetServices: { lexicon: 1, - id: 'app.bsky.feed.searchPosts', + id: 'app.bsky.labeler.getServices', defs: { main: { type: 'query', - description: 'Find posts matching search criteria.', + description: 'Get information about a list of labeler services.', parameters: { type: 'params', - required: ['q'], + required: ['dids'], properties: { - q: { - type: 'string', - description: - 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 25, + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, }, - cursor: { - type: 'string', - description: - 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', + detailed: { + type: 'boolean', + default: false, }, }, }, @@ -6728,62 +7225,43 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['posts'], + required: ['views'], properties: { - cursor: { - type: 'string', - }, - hitsTotal: { - type: 'integer', - description: - 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', - }, - posts: { + views: { type: 'array', items: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#postView', + type: 'union', + refs: [ + 'lex:app.bsky.labeler.defs#labelerView', + 'lex:app.bsky.labeler.defs#labelerViewDetailed', + ], }, }, }, }, }, - errors: [ - { - name: 'BadQueryString', - }, - ], }, }, }, - AppBskyFeedThreadgate: { + AppBskyLabelerService: { lexicon: 1, - id: 'app.bsky.feed.threadgate', + id: 'app.bsky.labeler.service', defs: { main: { type: 'record', - key: 'tid', - description: - "Defines interaction gating rules for a thread. The rkey of the threadgate record should match the rkey of the thread's root post.", + description: 'A declaration of the existence of labeler service.', + key: 'literal:self', record: { type: 'object', - required: ['post', 'createdAt'], + required: ['policies', 'createdAt'], properties: { - post: { - type: 'string', - format: 'at-uri', + policies: { + type: 'ref', + ref: 'lex:app.bsky.labeler.defs#labelerPolicies', }, - allow: { - type: 'array', - maxLength: 5, - items: { - type: 'union', - refs: [ - 'lex:app.bsky.feed.threadgate#mentionRule', - 'lex:app.bsky.feed.threadgate#followingRule', - 'lex:app.bsky.feed.threadgate#listRule', - ], - }, + labels: { + type: 'union', + refs: ['lex:com.atproto.label.defs#selfLabels'], }, createdAt: { type: 'string', @@ -6792,61 +7270,101 @@ export const schemaDict = { }, }, }, - mentionRule: { - type: 'object', - description: 'Allow replies from actors mentioned in your post.', - properties: {}, - }, - followingRule: { - type: 'object', - description: 'Allow replies from actors you follow.', - properties: {}, - }, - listRule: { - type: 'object', - description: 'Allow replies from actors on a list.', - required: ['list'], - properties: { - list: { - type: 'string', - format: 'at-uri', + }, + }, + AppBskyNotificationGetUnreadCount: { + lexicon: 1, + id: 'app.bsky.notification.getUnreadCount', + defs: { + main: { + type: 'query', + description: + 'Count the number of unread notifications for the requesting account. Requires auth.', + parameters: { + type: 'params', + properties: { + seenAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['count'], + properties: { + count: { + type: 'integer', + }, + }, }, }, }, }, }, - AppBskyGraphBlock: { + AppBskyNotificationListNotifications: { lexicon: 1, - id: 'app.bsky.graph.block', + id: 'app.bsky.notification.listNotifications', defs: { main: { - type: 'record', - description: 'A declaration of a block.', - key: 'tid', - record: { - type: 'object', - required: ['subject', 'createdAt'], + type: 'query', + description: + 'Enumerate notifications for the requesting account. Requires auth.', + parameters: { + type: 'params', properties: { - subject: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { type: 'string', - format: 'did', }, - createdAt: { + seenAt: { type: 'string', format: 'datetime', }, }, }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['notifications'], + properties: { + cursor: { + type: 'string', + }, + notifications: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.notification.listNotifications#notification', + }, + }, + seenAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, }, - }, - }, - AppBskyGraphDefs: { - lexicon: 1, - id: 'app.bsky.graph.defs', - defs: { - listViewBasic: { + notification: { type: 'object', - required: ['uri', 'cid', 'name', 'purpose'], + required: [ + 'uri', + 'cid', + 'author', + 'reason', + 'record', + 'isRead', + 'indexedAt', + ], properties: { uri: { type: 'string', @@ -6856,205 +7374,337 @@ export const schemaDict = { type: 'string', format: 'cid', }, - name: { - type: 'string', - maxLength: 64, - minLength: 1, - }, - purpose: { + author: { type: 'ref', - ref: 'lex:app.bsky.graph.defs#listPurpose', + ref: 'lex:app.bsky.actor.defs#profileView', }, - avatar: { + reason: { type: 'string', + description: + "Expected values are 'like', 'repost', 'follow', 'mention', 'reply', and 'quote'.", + knownValues: [ + 'like', + 'repost', + 'follow', + 'mention', + 'reply', + 'quote', + ], }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listViewerState', + reasonSubject: { + type: 'string', + format: 'at-uri', + }, + record: { + type: 'unknown', + }, + isRead: { + type: 'boolean', }, indexedAt: { type: 'string', format: 'datetime', }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + }, + }, + AppBskyNotificationRegisterPush: { + lexicon: 1, + id: 'app.bsky.notification.registerPush', + defs: { + main: { + type: 'procedure', + description: + 'Register to receive push notifications, via a specified service, for the requesting account. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['serviceDid', 'token', 'platform', 'appId'], + properties: { + serviceDid: { + type: 'string', + format: 'did', + }, + token: { + type: 'string', + }, + platform: { + type: 'string', + knownValues: ['ios', 'android', 'web'], + }, + appId: { + type: 'string', + }, + }, + }, }, }, - listView: { + }, + }, + AppBskyNotificationUpdateSeen: { + lexicon: 1, + id: 'app.bsky.notification.updateSeen', + defs: { + main: { + type: 'procedure', + description: + 'Notify server that the requesting account has seen notifications. Requires auth.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['seenAt'], + properties: { + seenAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, + }, + AppBskyRichtextFacet: { + lexicon: 1, + id: 'app.bsky.richtext.facet', + defs: { + main: { type: 'object', - required: ['uri', 'cid', 'creator', 'name', 'purpose', 'indexedAt'], + description: 'Annotation of a sub-string within rich text.', + required: ['index', 'features'], properties: { - uri: { - type: 'string', - format: 'at-uri', - }, - cid: { - type: 'string', - format: 'cid', - }, - creator: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - name: { - type: 'string', - maxLength: 64, - minLength: 1, - }, - purpose: { + index: { type: 'ref', - ref: 'lex:app.bsky.graph.defs#listPurpose', - }, - description: { - type: 'string', - maxGraphemes: 300, - maxLength: 3000, + ref: 'lex:app.bsky.richtext.facet#byteSlice', }, - descriptionFacets: { + features: { type: 'array', items: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet', + type: 'union', + refs: [ + 'lex:app.bsky.richtext.facet#mention', + 'lex:app.bsky.richtext.facet#link', + 'lex:app.bsky.richtext.facet#tag', + ], }, }, - avatar: { - type: 'string', - }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listViewerState', - }, - indexedAt: { + }, + }, + mention: { + type: 'object', + description: + "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", + required: ['did'], + properties: { + did: { type: 'string', - format: 'datetime', + format: 'did', }, }, }, - listItemView: { + link: { type: 'object', - required: ['uri', 'subject'], + description: + 'Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.', + required: ['uri'], properties: { uri: { type: 'string', - format: 'at-uri', - }, - subject: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + format: 'uri', }, }, }, - listPurpose: { - type: 'string', - knownValues: [ - 'app.bsky.graph.defs#modlist', - 'app.bsky.graph.defs#curatelist', - ], - }, - modlist: { - type: 'token', - description: - 'A list of actors to apply an aggregate moderation action (mute/block) on.', - }, - curatelist: { - type: 'token', + tag: { + type: 'object', description: - 'A list of actors used for curation purposes such as list feeds or interaction gating.', + "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", + required: ['tag'], + properties: { + tag: { + type: 'string', + maxLength: 640, + maxGraphemes: 64, + }, + }, }, - listViewerState: { + byteSlice: { type: 'object', + description: + 'Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.', + required: ['byteStart', 'byteEnd'], properties: { - muted: { - type: 'boolean', + byteStart: { + type: 'integer', + minimum: 0, }, - blocked: { - type: 'string', - format: 'at-uri', + byteEnd: { + type: 'integer', + minimum: 0, }, }, }, - notFoundActor: { + }, + }, + AppBskyUnspeccedDefs: { + lexicon: 1, + id: 'app.bsky.unspecced.defs', + defs: { + skeletonSearchPost: { type: 'object', - description: 'indicates that a handle or DID could not be resolved', - required: ['actor', 'notFound'], + required: ['uri'], properties: { - actor: { + uri: { type: 'string', - format: 'at-identifier', - }, - notFound: { - type: 'boolean', - const: true, + format: 'at-uri', }, }, }, - relationship: { + skeletonSearchActor: { type: 'object', - description: - 'lists the bi-directional graph relationships between one actor (not indicated in the object), and the target actors (the DID included in the object)', required: ['did'], properties: { did: { type: 'string', format: 'did', }, - following: { - type: 'string', - format: 'at-uri', - description: - 'if the actor follows this DID, this is the AT-URI of the follow record', - }, - followedBy: { - type: 'string', - format: 'at-uri', - description: - 'if the actor is followed by this DID, contains the AT-URI of the follow record', - }, }, }, }, }, - AppBskyGraphFollow: { + AppBskyUnspeccedGetPopularFeedGenerators: { lexicon: 1, - id: 'app.bsky.graph.follow', + id: 'app.bsky.unspecced.getPopularFeedGenerators', defs: { main: { - type: 'record', - description: 'A declaration of a social follow.', - key: 'tid', - record: { - type: 'object', - required: ['subject', 'createdAt'], + type: 'query', + description: 'An unspecced view of globally popular feed generators.', + parameters: { + type: 'params', properties: { - subject: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { type: 'string', - format: 'did', }, - createdAt: { + query: { type: 'string', - format: 'datetime', }, }, }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feeds'], + properties: { + cursor: { + type: 'string', + }, + feeds: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#generatorView', + }, + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetTaggedSuggestions: { + lexicon: 1, + id: 'app.bsky.unspecced.getTaggedSuggestions', + defs: { + main: { + type: 'query', + description: + 'Get a list of suggestions (feeds and users) tagged with categories', + parameters: { + type: 'params', + properties: {}, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['suggestions'], + properties: { + suggestions: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.getTaggedSuggestions#suggestion', + }, + }, + }, + }, + }, + }, + suggestion: { + type: 'object', + required: ['tag', 'subjectType', 'subject'], + properties: { + tag: { + type: 'string', + }, + subjectType: { + type: 'string', + knownValues: ['actor', 'feed'], + }, + subject: { + type: 'string', + format: 'uri', + }, + }, }, }, }, - AppBskyGraphGetBlocks: { + AppBskyUnspeccedSearchActorsSkeleton: { lexicon: 1, - id: 'app.bsky.graph.getBlocks', + id: 'app.bsky.unspecced.searchActorsSkeleton', defs: { main: { type: 'query', - description: 'Get a list of who the actor is blocking.', + description: 'Backend Actors (profile) search, returns only skeleton.', parameters: { type: 'params', + required: ['q'], properties: { + q: { + type: 'string', + description: + 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. For typeahead search, only simple term match is supported, not full syntax.', + }, + typeahead: { + type: 'boolean', + description: "If true, acts as fast/simple 'typeahead' query.", + }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 50, + default: 25, }, cursor: { type: 'string', + description: + 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', }, }, }, @@ -7062,47 +7712,60 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['blocks'], + required: ['actors'], properties: { cursor: { type: 'string', }, - blocks: { + hitsTotal: { + type: 'integer', + description: + 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', + }, + actors: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', }, }, }, }, }, + errors: [ + { + name: 'BadQueryString', + }, + ], }, }, }, - AppBskyGraphGetFollowers: { + AppBskyUnspeccedSearchPostsSkeleton: { lexicon: 1, - id: 'app.bsky.graph.getFollowers', + id: 'app.bsky.unspecced.searchPostsSkeleton', defs: { main: { type: 'query', - description: "Get a list of an actor's followers.", + description: 'Backend Posts search, returns only skeleton', parameters: { type: 'params', - required: ['actor'], + required: ['q'], properties: { - actor: { + q: { type: 'string', - format: 'at-identifier', + description: + 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 50, + default: 25, }, cursor: { type: 'string', + description: + 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', }, }, }, @@ -7110,72 +7773,171 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['subject', 'followers'], + required: ['posts'], properties: { - subject: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, cursor: { type: 'string', }, - followers: { + hitsTotal: { + type: 'integer', + description: + 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', + }, + posts: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchPost', }, }, }, }, }, + errors: [ + { + name: 'BadQueryString', + }, + ], }, }, }, - AppBskyGraphGetFollows: { + ToolsOzoneCommunicationCreateTemplate: { lexicon: 1, - id: 'app.bsky.graph.getFollows', + id: 'tools.ozone.communication.createTemplate', defs: { main: { - type: 'query', - description: 'Get a list of who the actor follows.', - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', + type: 'procedure', + description: + 'Administrative action to create a new, re-usable communication (email for now) template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'contentMarkdown', 'name'], + properties: { + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + createdBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is creating the template.', + }, }, }, }, output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:tools.ozone.communication.defs#templateView', + }, + }, + }, + }, + }, + ToolsOzoneCommunicationDefs: { + lexicon: 1, + id: 'tools.ozone.communication.defs', + defs: { + templateView: { + type: 'object', + required: [ + 'id', + 'name', + 'contentMarkdown', + 'disabled', + 'lastUpdatedBy', + 'createdAt', + 'updatedAt', + ], + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + subject: { + type: 'string', + description: + 'Content of the template, can contain markdown and variable placeholders.', + }, + contentMarkdown: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + disabled: { + type: 'boolean', + }, + lastUpdatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who last updated the template.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + updatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + ToolsOzoneCommunicationDeleteTemplate: { + lexicon: 1, + id: 'tools.ozone.communication.deleteTemplate', + defs: { + main: { + type: 'procedure', + description: 'Delete a communication template.', + input: { encoding: 'application/json', schema: { type: 'object', - required: ['subject', 'follows'], + required: ['id'], properties: { - subject: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - cursor: { + id: { type: 'string', }, - follows: { + }, + }, + }, + }, + }, + }, + ToolsOzoneCommunicationListTemplates: { + lexicon: 1, + id: 'tools.ozone.communication.listTemplates', + defs: { + main: { + type: 'query', + description: 'Get list of all communication templates.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['communicationTemplates'], + properties: { + communicationTemplates: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + ref: 'lex:tools.ozone.communication.defs#templateView', }, }, }, @@ -7184,617 +7946,648 @@ export const schemaDict = { }, }, }, - AppBskyGraphGetList: { + ToolsOzoneCommunicationUpdateTemplate: { lexicon: 1, - id: 'app.bsky.graph.getList', + id: 'tools.ozone.communication.updateTemplate', defs: { main: { - type: 'query', - description: 'Get a list of actors.', - parameters: { - type: 'params', - required: ['list'], - properties: { - list: { - type: 'string', - format: 'at-uri', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', + type: 'procedure', + description: + 'Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + description: 'ID of the template to be updated.', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + updatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is updating the template.', + }, + disabled: { + type: 'boolean', + }, }, }, }, output: { encoding: 'application/json', schema: { - type: 'object', - required: ['list', 'items'], - properties: { - cursor: { - type: 'string', - }, - list: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listView', - }, - items: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listItemView', - }, - }, - }, + type: 'ref', + ref: 'lex:tools.ozone.communication.defs#templateView', }, }, }, }, }, - AppBskyGraphGetListBlocks: { + ToolsOzoneModerationDefs: { lexicon: 1, - id: 'app.bsky.graph.getListBlocks', + id: 'tools.ozone.moderation.defs', defs: { - main: { - type: 'query', - description: 'Get lists that the actor is blocking.', - parameters: { - type: 'params', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { + modEventView: { + type: 'object', + required: [ + 'id', + 'event', + 'subject', + 'subjectBlobCids', + 'createdBy', + 'createdAt', + ], + properties: { + id: { + type: 'integer', + }, + event: { + type: 'union', + refs: [ + 'lex:tools.ozone.moderation.defs#modEventTakedown', + 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', + 'lex:tools.ozone.moderation.defs#modEventComment', + 'lex:tools.ozone.moderation.defs#modEventReport', + 'lex:tools.ozone.moderation.defs#modEventLabel', + 'lex:tools.ozone.moderation.defs#modEventAcknowledge', + 'lex:tools.ozone.moderation.defs#modEventEscalate', + 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventEmail', + 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', + 'lex:tools.ozone.moderation.defs#modEventDivert', + ], + }, + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + ], + }, + subjectBlobCids: { + type: 'array', + items: { type: 'string', }, }, + createdBy: { + type: 'string', + format: 'did', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + creatorHandle: { + type: 'string', + }, + subjectHandle: { + type: 'string', + }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['lists'], - properties: { - cursor: { - type: 'string', - }, - lists: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listView', - }, - }, + }, + modEventViewDetail: { + type: 'object', + required: [ + 'id', + 'event', + 'subject', + 'subjectBlobs', + 'createdBy', + 'createdAt', + ], + properties: { + id: { + type: 'integer', + }, + event: { + type: 'union', + refs: [ + 'lex:tools.ozone.moderation.defs#modEventTakedown', + 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', + 'lex:tools.ozone.moderation.defs#modEventComment', + 'lex:tools.ozone.moderation.defs#modEventReport', + 'lex:tools.ozone.moderation.defs#modEventLabel', + 'lex:tools.ozone.moderation.defs#modEventAcknowledge', + 'lex:tools.ozone.moderation.defs#modEventEscalate', + 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventEmail', + 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', + 'lex:tools.ozone.moderation.defs#modEventDivert', + ], + }, + subject: { + type: 'union', + refs: [ + 'lex:tools.ozone.moderation.defs#repoView', + 'lex:tools.ozone.moderation.defs#repoViewNotFound', + 'lex:tools.ozone.moderation.defs#recordView', + 'lex:tools.ozone.moderation.defs#recordViewNotFound', + ], + }, + subjectBlobs: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#blobView', + }, + }, + createdBy: { + type: 'string', + format: 'did', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + subjectStatusView: { + type: 'object', + required: ['id', 'subject', 'createdAt', 'updatedAt', 'reviewState'], + properties: { + id: { + type: 'integer', + }, + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + ], + }, + subjectBlobCids: { + type: 'array', + items: { + type: 'string', + format: 'cid', + }, + }, + subjectRepoHandle: { + type: 'string', + }, + updatedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the last update was made to the moderation status of the subject', + }, + createdAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing the first moderation status impacting event was emitted on the subject', + }, + reviewState: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#subjectReviewState', + }, + comment: { + type: 'string', + description: 'Sticky comment on the subject.', + }, + muteUntil: { + type: 'string', + format: 'datetime', + }, + lastReviewedBy: { + type: 'string', + format: 'did', + }, + lastReviewedAt: { + type: 'string', + format: 'datetime', + }, + lastReportedAt: { + type: 'string', + format: 'datetime', + }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the author of the subject appealed a moderation action', + }, + takendown: { + type: 'boolean', + }, + appealed: { + type: 'boolean', + description: + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + }, + suspendUntil: { + type: 'string', + format: 'datetime', + }, + tags: { + type: 'array', + items: { + type: 'string', }, }, }, }, - }, - }, - AppBskyGraphGetListMutes: { - lexicon: 1, - id: 'app.bsky.graph.getListMutes', - defs: { - main: { - type: 'query', - description: 'Get lists that the actor is muting.', - parameters: { - type: 'params', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', - }, + subjectReviewState: { + type: 'string', + knownValues: [ + 'lex:tools.ozone.moderation.defs#reviewOpen', + 'lex:tools.ozone.moderation.defs#reviewEscalated', + 'lex:tools.ozone.moderation.defs#reviewClosed', + 'lex:tools.ozone.moderation.defs#reviewNone', + ], + }, + reviewOpen: { + type: 'token', + description: + 'Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator', + }, + reviewEscalated: { + type: 'token', + description: + 'Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator', + }, + reviewClosed: { + type: 'token', + description: + 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', + }, + reviewNone: { + type: 'token', + description: + 'Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it', + }, + modEventTakedown: { + type: 'object', + description: 'Take down a subject permanently or temporarily', + properties: { + comment: { + type: 'string', + }, + durationInHours: { + type: 'integer', + description: + 'Indicates how long the takedown should be in effect before automatically expiring.', + }, + }, + }, + modEventReverseTakedown: { + type: 'object', + description: 'Revert take down action on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe reasoning behind the reversal.', }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['lists'], - properties: { - cursor: { - type: 'string', - }, - lists: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listView', - }, - }, - }, + }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', }, }, }, - }, - }, - AppBskyGraphGetLists: { - lexicon: 1, - id: 'app.bsky.graph.getLists', - defs: { - main: { - type: 'query', - description: 'Get a list of lists that belong to an actor.', - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', - }, + modEventComment: { + type: 'object', + description: 'Add a comment to a subject', + required: ['comment'], + properties: { + comment: { + type: 'string', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['lists'], - properties: { - cursor: { - type: 'string', - }, - lists: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listView', - }, - }, - }, + sticky: { + type: 'boolean', + description: 'Make the comment persistent on the subject', }, }, }, - }, - }, - AppBskyGraphGetMutes: { - lexicon: 1, - id: 'app.bsky.graph.getMutes', - defs: { - main: { - type: 'query', - description: 'Get a list of who the actor mutes.', - parameters: { - type: 'params', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', - }, + modEventReport: { + type: 'object', + description: 'Report a subject', + required: ['reportType'], + properties: { + comment: { + type: 'string', }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['mutes'], - properties: { - cursor: { - type: 'string', - }, - mutes: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - }, - }, + reportType: { + type: 'ref', + ref: 'lex:com.atproto.moderation.defs#reasonType', }, }, }, - }, - }, - AppBskyGraphGetRelationships: { - lexicon: 1, - id: 'app.bsky.graph.getRelationships', - defs: { - main: { - type: 'query', - description: - 'Enumerates public relationships between one account, and a list of other accounts', - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { + modEventLabel: { + type: 'object', + description: 'Apply/Negate labels on a subject', + required: ['createLabelVals', 'negateLabelVals'], + properties: { + comment: { + type: 'string', + }, + createLabelVals: { + type: 'array', + items: { type: 'string', - format: 'at-identifier', - }, - others: { - type: 'array', - maxLength: 30, - items: { - type: 'string', - format: 'at-identifier', - }, }, }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['relationships'], - properties: { - actor: { - type: 'string', - format: 'did', - }, - relationships: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:app.bsky.graph.defs#relationship', - 'lex:app.bsky.graph.defs#notFoundActor', - ], - }, - }, + negateLabelVals: { + type: 'array', + items: { + type: 'string', }, }, }, - errors: [ - { - name: 'ActorNotFound', - description: - 'the primary actor at-identifier could not be resolved', + }, + modEventAcknowledge: { + type: 'object', + properties: { + comment: { + type: 'string', }, - ], + }, }, - }, - }, - AppBskyGraphGetSuggestedFollowsByActor: { - lexicon: 1, - id: 'app.bsky.graph.getSuggestedFollowsByActor', - defs: { - main: { - type: 'query', - description: 'Get suggested follows related to a given actor.', - parameters: { - type: 'params', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, + modEventEscalate: { + type: 'object', + properties: { + comment: { + type: 'string', + }, + }, + }, + modEventMute: { + type: 'object', + description: 'Mute incoming reports on a subject', + required: ['durationInHours'], + properties: { + comment: { + type: 'string', + }, + durationInHours: { + type: 'integer', + description: 'Indicates how long the subject should remain muted.', }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['suggestions'], - properties: { - suggestions: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', - }, - }, - }, + }, + modEventUnmute: { + type: 'object', + description: 'Unmute action on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe reasoning behind the reversal.', }, }, }, - }, - }, - AppBskyGraphList: { - lexicon: 1, - id: 'app.bsky.graph.list', - defs: { - main: { - type: 'record', - description: 'A declaration of a list of actors.', - key: 'tid', - record: { - type: 'object', - required: ['name', 'purpose', 'createdAt'], - properties: { - purpose: { - type: 'ref', - ref: 'lex:app.bsky.graph.defs#listPurpose', - }, - name: { - type: 'string', - maxLength: 64, - minLength: 1, - }, - description: { - type: 'string', - maxGraphemes: 300, - maxLength: 3000, - }, - descriptionFacets: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet', - }, - }, - avatar: { - type: 'blob', - accept: ['image/png', 'image/jpeg'], - maxSize: 1000000, - }, - labels: { - type: 'union', - refs: ['lex:com.atproto.label.defs#selfLabels'], - }, - createdAt: { - type: 'string', - format: 'datetime', - }, + modEventEmail: { + type: 'object', + description: 'Keep a log of outgoing email to a user', + required: ['subjectLine'], + properties: { + subjectLine: { + type: 'string', + description: 'The subject line of the email sent to the user.', + }, + content: { + type: 'string', + description: 'The content of the email sent to the user.', + }, + comment: { + type: 'string', + description: 'Additional comment about the outgoing comm.', }, }, }, - }, - }, - AppBskyGraphListblock: { - lexicon: 1, - id: 'app.bsky.graph.listblock', - defs: { - main: { - type: 'record', - description: 'A block of an entire list of actors.', - key: 'tid', - record: { - type: 'object', - required: ['subject', 'createdAt'], - properties: { - subject: { - type: 'string', - format: 'at-uri', - }, - createdAt: { - type: 'string', - format: 'datetime', - }, + modEventDivert: { + type: 'object', + description: + "Divert a record's blobs to a 3rd party service for further scanning/tagging", + properties: { + comment: { + type: 'string', }, }, }, - }, - }, - AppBskyGraphListitem: { - lexicon: 1, - id: 'app.bsky.graph.listitem', - defs: { - main: { - type: 'record', - description: 'An item under a declared list of actors.', - key: 'tid', - record: { - type: 'object', - required: ['subject', 'list', 'createdAt'], - properties: { - subject: { - type: 'string', - format: 'did', - }, - list: { + modEventTag: { + type: 'object', + description: 'Add/Remove a tag on a subject', + required: ['add', 'remove'], + properties: { + add: { + type: 'array', + items: { type: 'string', - format: 'at-uri', }, - createdAt: { + description: + "Tags to be added to the subject. If already exists, won't be duplicated.", + }, + remove: { + type: 'array', + items: { type: 'string', - format: 'datetime', }, + description: + "Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated.", + }, + comment: { + type: 'string', + description: 'Additional comment about added/removed tags.', }, }, }, - }, - }, - AppBskyGraphMuteActor: { - lexicon: 1, - id: 'app.bsky.graph.muteActor', - defs: { - main: { - type: 'procedure', - description: 'Mute an actor by DID or handle.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, + repoView: { + type: 'object', + required: [ + 'did', + 'handle', + 'relatedRecords', + 'indexedAt', + 'moderation', + ], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + email: { + type: 'string', + }, + relatedRecords: { + type: 'array', + items: { + type: 'unknown', }, }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + moderation: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#moderation', + }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invitesDisabled: { + type: 'boolean', + }, + inviteNote: { + type: 'string', + }, }, }, - }, - }, - AppBskyGraphMuteActorList: { - lexicon: 1, - id: 'app.bsky.graph.muteActorList', - defs: { - main: { - type: 'procedure', - description: 'Mute a list of actors.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['list'], - properties: { - list: { - type: 'string', - format: 'at-uri', - }, + repoViewDetail: { + type: 'object', + required: [ + 'did', + 'handle', + 'relatedRecords', + 'indexedAt', + 'moderation', + ], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + email: { + type: 'string', + }, + relatedRecords: { + type: 'array', + items: { + type: 'unknown', }, }, - }, - }, - }, - }, - AppBskyGraphUnmuteActor: { - lexicon: 1, - id: 'app.bsky.graph.unmuteActor', - defs: { - main: { - type: 'procedure', - description: 'Unmute an actor by DID or handle.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['actor'], - properties: { - actor: { - type: 'string', - format: 'at-identifier', - }, - }, + indexedAt: { + type: 'string', + format: 'datetime', }, - }, - }, - }, - }, - AppBskyGraphUnmuteActorList: { - lexicon: 1, - id: 'app.bsky.graph.unmuteActorList', - defs: { - main: { - type: 'procedure', - description: 'Unmute a list of actors.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['list'], - properties: { - list: { - type: 'string', - format: 'at-uri', - }, + moderation: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#moderationDetail', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', }, }, - }, - }, - }, - }, - AppBskyNotificationGetUnreadCount: { - lexicon: 1, - id: 'app.bsky.notification.getUnreadCount', - defs: { - main: { - type: 'query', - description: 'Get the count of unread notifications.', - parameters: { - type: 'params', - properties: { - seenAt: { - type: 'string', - format: 'datetime', + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', }, }, + invitesDisabled: { + type: 'boolean', + }, + inviteNote: { + type: 'string', + }, + emailConfirmedAt: { + type: 'string', + format: 'datetime', + }, }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['count'], - properties: { - count: { - type: 'integer', - }, - }, + }, + repoViewNotFound: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', }, }, }, - }, - }, - AppBskyNotificationListNotifications: { - lexicon: 1, - id: 'app.bsky.notification.listNotifications', - defs: { - main: { - type: 'query', - description: 'Get a list of notifications.', - parameters: { - type: 'params', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: 'string', - }, - seenAt: { + recordView: { + type: 'object', + required: [ + 'uri', + 'cid', + 'value', + 'blobCids', + 'indexedAt', + 'moderation', + 'repo', + ], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + value: { + type: 'unknown', + }, + blobCids: { + type: 'array', + items: { type: 'string', - format: 'datetime', + format: 'cid', }, }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['notifications'], - properties: { - cursor: { - type: 'string', - }, - notifications: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.notification.listNotifications#notification', - }, - }, - seenAt: { - type: 'string', - format: 'datetime', - }, - }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + moderation: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#moderation', + }, + repo: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#repoView', }, }, }, - notification: { + recordViewDetail: { type: 'object', required: [ 'uri', 'cid', - 'author', - 'reason', - 'record', - 'isRead', + 'value', + 'blobs', 'indexedAt', + 'moderation', + 'repo', ], properties: { uri: { @@ -7805,227 +8598,387 @@ export const schemaDict = { type: 'string', format: 'cid', }, - author: { - type: 'ref', - ref: 'lex:app.bsky.actor.defs#profileView', + value: { + type: 'unknown', + }, + blobs: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#blobView', + }, + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + moderation: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#moderationDetail', + }, + repo: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#repoView', + }, + }, + }, + recordViewNotFound: { + type: 'object', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + }, + }, + moderation: { + type: 'object', + properties: { + subjectStatus: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#subjectStatusView', + }, + }, + }, + moderationDetail: { + type: 'object', + properties: { + subjectStatus: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#subjectStatusView', + }, + }, + }, + blobView: { + type: 'object', + required: ['cid', 'mimeType', 'size', 'createdAt'], + properties: { + cid: { + type: 'string', + format: 'cid', + }, + mimeType: { + type: 'string', }, - reason: { + size: { + type: 'integer', + }, + createdAt: { type: 'string', - description: - "Expected values are 'like', 'repost', 'follow', 'mention', 'reply', and 'quote'.", - knownValues: [ - 'like', - 'repost', - 'follow', - 'mention', - 'reply', - 'quote', + format: 'datetime', + }, + details: { + type: 'union', + refs: [ + 'lex:tools.ozone.moderation.defs#imageDetails', + 'lex:tools.ozone.moderation.defs#videoDetails', ], }, - reasonSubject: { - type: 'string', - format: 'at-uri', + moderation: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#moderation', }, - record: { - type: 'unknown', + }, + }, + imageDetails: { + type: 'object', + required: ['width', 'height'], + properties: { + width: { + type: 'integer', }, - isRead: { - type: 'boolean', + height: { + type: 'integer', }, - indexedAt: { - type: 'string', - format: 'datetime', + }, + }, + videoDetails: { + type: 'object', + required: ['width', 'height', 'length'], + properties: { + width: { + type: 'integer', }, - labels: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:com.atproto.label.defs#label', - }, + height: { + type: 'integer', + }, + length: { + type: 'integer', }, }, }, }, }, - AppBskyNotificationRegisterPush: { + ToolsOzoneModerationEmitEvent: { lexicon: 1, - id: 'app.bsky.notification.registerPush', + id: 'tools.ozone.moderation.emitEvent', defs: { main: { type: 'procedure', - description: 'Register for push notifications with a service.', + description: 'Take a moderation action on an actor.', input: { encoding: 'application/json', schema: { type: 'object', - required: ['serviceDid', 'token', 'platform', 'appId'], + required: ['event', 'subject', 'createdBy'], properties: { - serviceDid: { - type: 'string', - format: 'did', + event: { + type: 'union', + refs: [ + 'lex:tools.ozone.moderation.defs#modEventTakedown', + 'lex:tools.ozone.moderation.defs#modEventAcknowledge', + 'lex:tools.ozone.moderation.defs#modEventEscalate', + 'lex:tools.ozone.moderation.defs#modEventComment', + 'lex:tools.ozone.moderation.defs#modEventLabel', + 'lex:tools.ozone.moderation.defs#modEventReport', + 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventEmail', + 'lex:tools.ozone.moderation.defs#modEventTag', + ], }, - token: { - type: 'string', + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + ], }, - platform: { - type: 'string', - knownValues: ['ios', 'android', 'web'], + subjectBlobCids: { + type: 'array', + items: { + type: 'string', + format: 'cid', + }, }, - appId: { + createdBy: { type: 'string', + format: 'did', }, }, }, }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#modEventView', + }, + }, + errors: [ + { + name: 'SubjectHasAction', + }, + ], }, }, }, - AppBskyNotificationUpdateSeen: { + ToolsOzoneModerationGetEvent: { lexicon: 1, - id: 'app.bsky.notification.updateSeen', + id: 'tools.ozone.moderation.getEvent', defs: { main: { - type: 'procedure', - description: 'Notify server that the user has seen notifications.', - input: { + type: 'query', + description: 'Get details about a moderation event.', + parameters: { + type: 'params', + required: ['id'], + properties: { + id: { + type: 'integer', + }, + }, + }, + output: { encoding: 'application/json', schema: { - type: 'object', - required: ['seenAt'], - properties: { - seenAt: { - type: 'string', - format: 'datetime', - }, - }, + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#modEventViewDetail', }, }, }, }, }, - AppBskyRichtextFacet: { + ToolsOzoneModerationGetRecord: { lexicon: 1, - id: 'app.bsky.richtext.facet', + id: 'tools.ozone.moderation.getRecord', defs: { main: { - type: 'object', - required: ['index', 'features'], - properties: { - index: { - type: 'ref', - ref: 'lex:app.bsky.richtext.facet#byteSlice', - }, - features: { - type: 'array', - items: { - type: 'union', - refs: [ - 'lex:app.bsky.richtext.facet#mention', - 'lex:app.bsky.richtext.facet#link', - 'lex:app.bsky.richtext.facet#tag', - ], + type: 'query', + description: 'Get details about a record.', + parameters: { + type: 'params', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', }, }, }, - }, - mention: { - type: 'object', - description: 'A facet feature for actor mentions.', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - }, - }, - }, - link: { - type: 'object', - description: 'A facet feature for links.', - required: ['uri'], - properties: { - uri: { - type: 'string', - format: 'uri', - }, - }, - }, - tag: { - type: 'object', - description: 'A hashtag.', - required: ['tag'], - properties: { - tag: { - type: 'string', - maxLength: 640, - maxGraphemes: 64, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#recordViewDetail', }, }, - }, - byteSlice: { - type: 'object', - description: - 'A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings.', - required: ['byteStart', 'byteEnd'], - properties: { - byteStart: { - type: 'integer', - minimum: 0, - }, - byteEnd: { - type: 'integer', - minimum: 0, + errors: [ + { + name: 'RecordNotFound', }, - }, + ], }, }, }, - AppBskyUnspeccedDefs: { + ToolsOzoneModerationGetRepo: { lexicon: 1, - id: 'app.bsky.unspecced.defs', + id: 'tools.ozone.moderation.getRepo', defs: { - skeletonSearchPost: { - type: 'object', - required: ['uri'], - properties: { - uri: { - type: 'string', - format: 'at-uri', + main: { + type: 'query', + description: 'Get details about a repository.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, }, }, - }, - skeletonSearchActor: { - type: 'object', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:tools.ozone.moderation.defs#repoViewDetail', }, }, + errors: [ + { + name: 'RepoNotFound', + }, + ], }, }, }, - AppBskyUnspeccedGetPopularFeedGenerators: { + ToolsOzoneModerationQueryEvents: { lexicon: 1, - id: 'app.bsky.unspecced.getPopularFeedGenerators', + id: 'tools.ozone.moderation.queryEvents', defs: { main: { type: 'query', - description: 'An unspecced view of globally popular feed generators.', + description: 'List moderation events related to a subject.', parameters: { type: 'params', properties: { + types: { + type: 'array', + items: { + type: 'string', + }, + description: + 'The types of events (fully qualified string in the format of tools.ozone.moderation.defs#modEvent) to filter by. If not specified, all events are returned.', + }, + createdBy: { + type: 'string', + format: 'did', + }, + sortDirection: { + type: 'string', + default: 'desc', + enum: ['asc', 'desc'], + description: + 'Sort direction for the events. Defaults to descending order of created at timestamp.', + }, + createdAfter: { + type: 'string', + format: 'datetime', + description: 'Retrieve events created after a given timestamp', + }, + createdBefore: { + type: 'string', + format: 'datetime', + description: 'Retrieve events created before a given timestamp', + }, + subject: { + type: 'string', + format: 'uri', + }, + includeAllUserRecords: { + type: 'boolean', + default: false, + description: + 'If true, events on all record types (posts, lists, profile etc.) owned by the did are returned', + }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 50, }, - cursor: { + hasComment: { + type: 'boolean', + description: 'If true, only events with comments are returned', + }, + comment: { type: 'string', + description: + 'If specified, only events with comments containing the keyword are returned', }, - query: { + addedLabels: { + type: 'array', + items: { + type: 'string', + }, + description: + 'If specified, only events where all of these labels were added are returned', + }, + removedLabels: { + type: 'array', + items: { + type: 'string', + }, + description: + 'If specified, only events where all of these labels were removed are returned', + }, + addedTags: { + type: 'array', + items: { + type: 'string', + }, + description: + 'If specified, only events where all of these tags were added are returned', + }, + removedTags: { + type: 'array', + items: { + type: 'string', + }, + description: + 'If specified, only events where all of these tags were removed are returned', + }, + reportTypes: { + type: 'array', + items: { + type: 'string', + }, + }, + cursor: { type: 'string', }, }, @@ -8034,16 +8987,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['feeds'], + required: ['events'], properties: { cursor: { type: 'string', }, - feeds: { + events: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.feed.defs#generatorView', + ref: 'lex:tools.ozone.moderation.defs#modEventView', }, }, }, @@ -8052,84 +9005,104 @@ export const schemaDict = { }, }, }, - AppBskyUnspeccedGetTaggedSuggestions: { + ToolsOzoneModerationQueryStatuses: { lexicon: 1, - id: 'app.bsky.unspecced.getTaggedSuggestions', + id: 'tools.ozone.moderation.queryStatuses', defs: { main: { type: 'query', - description: - 'Get a list of suggestions (feeds and users) tagged with categories', + description: 'View moderation statuses of subjects (record or repo).', parameters: { type: 'params', - properties: {}, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['suggestions'], - properties: { - suggestions: { - type: 'array', - items: { - type: 'ref', - ref: 'lex:app.bsky.unspecced.getTaggedSuggestions#suggestion', - }, + properties: { + subject: { + type: 'string', + format: 'uri', + }, + comment: { + type: 'string', + description: 'Search subjects by keyword from comments', + }, + reportedAfter: { + type: 'string', + format: 'datetime', + description: 'Search subjects reported after a given timestamp', + }, + reportedBefore: { + type: 'string', + format: 'datetime', + description: 'Search subjects reported before a given timestamp', + }, + reviewedAfter: { + type: 'string', + format: 'datetime', + description: 'Search subjects reviewed after a given timestamp', + }, + reviewedBefore: { + type: 'string', + format: 'datetime', + description: 'Search subjects reviewed before a given timestamp', + }, + includeMuted: { + type: 'boolean', + description: + "By default, we don't include muted subjects in the results. Set this to true to include them.", + }, + reviewState: { + type: 'string', + description: 'Specify when fetching subjects in a certain state', + }, + ignoreSubjects: { + type: 'array', + items: { + type: 'string', + format: 'uri', }, }, - }, - }, - }, - suggestion: { - type: 'object', - required: ['tag', 'subjectType', 'subject'], - properties: { - tag: { - type: 'string', - }, - subjectType: { - type: 'string', - knownValues: ['actor', 'feed'], - }, - subject: { - type: 'string', - format: 'uri', - }, - }, - }, - }, - }, - AppBskyUnspeccedSearchActorsSkeleton: { - lexicon: 1, - id: 'app.bsky.unspecced.searchActorsSkeleton', - defs: { - main: { - type: 'query', - description: 'Backend Actors (profile) search, returns only skeleton.', - parameters: { - type: 'params', - required: ['q'], - properties: { - q: { + lastReviewedBy: { type: 'string', + format: 'did', description: - 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended. For typeahead search, only simple term match is supported, not full syntax.', + 'Get all subject statuses that were reviewed by a specific moderator', }, - typeahead: { + sortField: { + type: 'string', + default: 'lastReportedAt', + enum: ['lastReviewedAt', 'lastReportedAt'], + }, + sortDirection: { + type: 'string', + default: 'desc', + enum: ['asc', 'desc'], + }, + takendown: { type: 'boolean', - description: "If true, acts as fast/simple 'typeahead' query.", + description: 'Get subjects that were taken down', + }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 25, + default: 50, + }, + tags: { + type: 'array', + items: { + type: 'string', + }, + }, + excludeTags: { + type: 'array', + items: { + type: 'string', + }, }, cursor: { type: 'string', - description: - 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', }, }, }, @@ -8137,60 +9110,49 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['actors'], + required: ['subjectStatuses'], properties: { cursor: { type: 'string', }, - hitsTotal: { - type: 'integer', - description: - 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', - }, - actors: { + subjectStatuses: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', + ref: 'lex:tools.ozone.moderation.defs#subjectStatusView', }, }, }, }, }, - errors: [ - { - name: 'BadQueryString', - }, - ], }, }, }, - AppBskyUnspeccedSearchPostsSkeleton: { + ToolsOzoneModerationSearchRepos: { lexicon: 1, - id: 'app.bsky.unspecced.searchPostsSkeleton', + id: 'tools.ozone.moderation.searchRepos', defs: { main: { type: 'query', - description: 'Backend Posts search, returns only skeleton', + description: 'Find repositories based on a search term.', parameters: { type: 'params', - required: ['q'], properties: { + term: { + type: 'string', + description: "DEPRECATED: use 'q' instead", + }, q: { type: 'string', - description: - 'Search query string; syntax, phrase, boolean, and faceting is unspecified, but Lucene query syntax is recommended.', }, limit: { type: 'integer', minimum: 1, maximum: 100, - default: 25, + default: 50, }, cursor: { type: 'string', - description: - 'Optional pagination mechanism; may not necessarily allow scrolling through entire result set.', }, }, }, @@ -8198,31 +9160,21 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['posts'], + required: ['repos'], properties: { cursor: { type: 'string', }, - hitsTotal: { - type: 'integer', - description: - 'Count of search hits. Optional, may be rounded/truncated, and may not be possible to paginate through all hits.', - }, - posts: { + repos: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.unspecced.defs#skeletonSearchPost', + ref: 'lex:tools.ozone.moderation.defs#repoView', }, }, }, }, }, - errors: [ - { - name: 'BadQueryString', - }, - ], }, }, }, @@ -8230,38 +9182,30 @@ export const schemaDict = { export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { - ComAtprotoAdminCreateCommunicationTemplate: - 'com.atproto.admin.createCommunicationTemplate', ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', - ComAtprotoAdminDeleteCommunicationTemplate: - 'com.atproto.admin.deleteCommunicationTemplate', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', - ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', - ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent', - ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', - ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', - ComAtprotoAdminListCommunicationTemplates: - 'com.atproto.admin.listCommunicationTemplates', - ComAtprotoAdminQueryModerationEvents: - 'com.atproto.admin.queryModerationEvents', - ComAtprotoAdminQueryModerationStatuses: - 'com.atproto.admin.queryModerationStatuses', - ComAtprotoAdminSearchRepos: 'com.atproto.admin.searchRepos', ComAtprotoAdminSendEmail: 'com.atproto.admin.sendEmail', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', - ComAtprotoAdminUpdateCommunicationTemplate: - 'com.atproto.admin.updateCommunicationTemplate', + ComAtprotoAdminUpdateAccountPassword: + 'com.atproto.admin.updateAccountPassword', ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', + ComAtprotoIdentityGetRecommendedDidCredentials: + 'com.atproto.identity.getRecommendedDidCredentials', + ComAtprotoIdentityRequestPlcOperationSignature: + 'com.atproto.identity.requestPlcOperationSignature', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', + ComAtprotoIdentitySignPlcOperation: 'com.atproto.identity.signPlcOperation', + ComAtprotoIdentitySubmitPlcOperation: + 'com.atproto.identity.submitPlcOperation', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', ComAtprotoLabelDefs: 'com.atproto.label.defs', ComAtprotoLabelQueryLabels: 'com.atproto.label.queryLabels', @@ -8273,22 +9217,28 @@ export const ids = { ComAtprotoRepoDeleteRecord: 'com.atproto.repo.deleteRecord', ComAtprotoRepoDescribeRepo: 'com.atproto.repo.describeRepo', ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord', + ComAtprotoRepoImportRepo: 'com.atproto.repo.importRepo', + ComAtprotoRepoListMissingBlobs: 'com.atproto.repo.listMissingBlobs', ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords', ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerActivateAccount: 'com.atproto.server.activateAccount', + ComAtprotoServerCheckAccountStatus: 'com.atproto.server.checkAccountStatus', ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', ComAtprotoServerCreateInviteCodes: 'com.atproto.server.createInviteCodes', ComAtprotoServerCreateSession: 'com.atproto.server.createSession', + ComAtprotoServerDeactivateAccount: 'com.atproto.server.deactivateAccount', ComAtprotoServerDefs: 'com.atproto.server.defs', ComAtprotoServerDeleteAccount: 'com.atproto.server.deleteAccount', ComAtprotoServerDeleteSession: 'com.atproto.server.deleteSession', ComAtprotoServerDescribeServer: 'com.atproto.server.describeServer', ComAtprotoServerGetAccountInviteCodes: 'com.atproto.server.getAccountInviteCodes', + ComAtprotoServerGetServiceAuth: 'com.atproto.server.getServiceAuth', ComAtprotoServerGetSession: 'com.atproto.server.getSession', ComAtprotoServerListAppPasswords: 'com.atproto.server.listAppPasswords', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', @@ -8317,11 +9267,8 @@ export const ids = { ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempCheckSignupQueue: 'com.atproto.temp.checkSignupQueue', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', - ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', - ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', - ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', AppBskyActorGetProfile: 'app.bsky.actor.getProfile', @@ -8378,6 +9325,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', @@ -8392,4 +9342,21 @@ export const ids = { AppBskyUnspeccedSearchActorsSkeleton: 'app.bsky.unspecced.searchActorsSkeleton', AppBskyUnspeccedSearchPostsSkeleton: 'app.bsky.unspecced.searchPostsSkeleton', + ToolsOzoneCommunicationCreateTemplate: + 'tools.ozone.communication.createTemplate', + ToolsOzoneCommunicationDefs: 'tools.ozone.communication.defs', + ToolsOzoneCommunicationDeleteTemplate: + 'tools.ozone.communication.deleteTemplate', + ToolsOzoneCommunicationListTemplates: + 'tools.ozone.communication.listTemplates', + ToolsOzoneCommunicationUpdateTemplate: + 'tools.ozone.communication.updateTemplate', + ToolsOzoneModerationDefs: 'tools.ozone.moderation.defs', + ToolsOzoneModerationEmitEvent: 'tools.ozone.moderation.emitEvent', + ToolsOzoneModerationGetEvent: 'tools.ozone.moderation.getEvent', + ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord', + ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo', + ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents', + ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses', + ToolsOzoneModerationSearchRepos: 'tools.ozone.moderation.searchRepos', } 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 5c1791e6130..d82df90d96c 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -13,6 +13,7 @@ export interface ProfileViewBasic { handle: string displayName?: string avatar?: string + associated?: ProfileAssociated viewer?: ViewerState labels?: ComAtprotoLabelDefs.Label[] [k: string]: unknown @@ -36,6 +37,7 @@ export interface ProfileView { displayName?: string description?: string avatar?: string + associated?: ProfileAssociated indexedAt?: string viewer?: ViewerState labels?: ComAtprotoLabelDefs.Label[] @@ -64,6 +66,7 @@ export interface ProfileViewDetailed { followersCount?: number followsCount?: number postsCount?: number + associated?: ProfileAssociated indexedAt?: string viewer?: ViewerState labels?: ComAtprotoLabelDefs.Label[] @@ -82,6 +85,26 @@ 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 mutedByList?: AppBskyGraphDefs.ListViewBasic @@ -113,6 +136,8 @@ export type Preferences = ( | FeedViewPref | ThreadViewPref | InterestsPref + | MutedWordsPref + | HiddenPostsPref | { $type: string; [k: string]: unknown } )[] @@ -134,8 +159,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 } @@ -154,6 +181,7 @@ export function validateContentLabelPref(v: unknown): ValidationResult { export interface SavedFeedsPref { pinned: string[] saved: string[] + timelineIndex?: number [k: string]: unknown } @@ -193,7 +221,7 @@ export interface FeedViewPref { /** Hide replies in the feed. */ hideReplies?: boolean /** Hide replies in the feed if they are not by followed users. */ - hideRepliesByUnfollowed?: boolean + hideRepliesByUnfollowed: boolean /** Hide replies in the feed if they do not have this number of likes. */ hideRepliesByLikeCount?: number /** Hide reposts in the feed. */ @@ -252,3 +280,96 @@ export function isInterestsPref(v: unknown): v is InterestsPref { export function validateInterestsPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#interestsPref', v) } + +export type MutedWordTarget = 'content' | 'tag' | (string & {}) + +/** A word that the account owner has muted. */ +export interface MutedWord { + /** The muted word itself. */ + value: string + /** The intended targets of the muted word. */ + targets: MutedWordTarget[] + [k: string]: unknown +} + +export function isMutedWord(v: unknown): v is MutedWord { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#mutedWord' + ) +} + +export function validateMutedWord(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#mutedWord', v) +} + +export interface MutedWordsPref { + /** A list of words the account owner has muted. */ + items: MutedWord[] + [k: string]: unknown +} + +export function isMutedWordsPref(v: unknown): v is MutedWordsPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#mutedWordsPref' + ) +} + +export function validateMutedWordsPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#mutedWordsPref', v) +} + +export interface HiddenPostsPref { + /** A list of URIs of posts the account owner has hidden. */ + items: string[] + [k: string]: unknown +} + +export function isHiddenPostsPref(v: unknown): v is HiddenPostsPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#hiddenPostsPref' + ) +} + +export function validateHiddenPostsPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v) +} + +export interface LabelersPref { + labelers: LabelerPrefItem[] + [k: string]: unknown +} + +export function isLabelersPref(v: unknown): v is LabelersPref { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#labelersPref' + ) +} + +export function validateLabelersPref(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#labelersPref', v) +} + +export interface LabelerPrefItem { + did: string + [k: string]: unknown +} + +export function isLabelerPrefItem(v: unknown): v is LabelerPrefItem { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#labelerPrefItem' + ) +} + +export function validateLabelerPrefItem(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#labelerPrefItem', v) +} diff --git a/packages/api/src/client/types/app/bsky/actor/getProfile.ts b/packages/api/src/client/types/app/bsky/actor/getProfile.ts index 47e36fe974a..bbd88c30a7b 100644 --- a/packages/api/src/client/types/app/bsky/actor/getProfile.ts +++ b/packages/api/src/client/types/app/bsky/actor/getProfile.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyActorDefs from './defs' export interface QueryParams { + /** Handle or DID of account to fetch profile of. */ actor: string } diff --git a/packages/api/src/client/types/app/bsky/actor/profile.ts b/packages/api/src/client/types/app/bsky/actor/profile.ts index fa36f4298f1..a0c51e060c5 100644 --- a/packages/api/src/client/types/app/bsky/actor/profile.ts +++ b/packages/api/src/client/types/app/bsky/actor/profile.ts @@ -9,8 +9,11 @@ import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' export interface Record { displayName?: string + /** Free-form profile description text. */ description?: string + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ avatar?: BlobRef + /** Larger horizontal image to display behind profile view. */ banner?: BlobRef labels?: | ComAtprotoLabelDefs.SelfLabels diff --git a/packages/api/src/client/types/app/bsky/embed/external.ts b/packages/api/src/client/types/app/bsky/embed/external.ts index 271c103dbba..5832cbb3987 100644 --- a/packages/api/src/client/types/app/bsky/embed/external.ts +++ b/packages/api/src/client/types/app/bsky/embed/external.ts @@ -6,6 +6,7 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +/** A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post). */ export interface Main { external: External [k: string]: unknown diff --git a/packages/api/src/client/types/app/bsky/embed/images.ts b/packages/api/src/client/types/app/bsky/embed/images.ts index 77909a4b3b0..ddfdf4c156c 100644 --- a/packages/api/src/client/types/app/bsky/embed/images.ts +++ b/packages/api/src/client/types/app/bsky/embed/images.ts @@ -26,6 +26,7 @@ export function validateMain(v: unknown): ValidationResult { export interface Image { image: BlobRef + /** Alt text description of the image, for accessibility. */ alt: string aspectRatio?: AspectRatio [k: string]: unknown @@ -76,8 +77,11 @@ export function validateView(v: unknown): ValidationResult { } export interface ViewImage { + /** Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View. */ thumb: string + /** Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View. */ fullsize: string + /** Alt text description of the image, for accessibility. */ alt: string aspectRatio?: AspectRatio [k: string]: unknown 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 caee8f08cdd..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 } @@ -57,6 +59,7 @@ export interface ViewRecord { uri: string cid: string author: AppBskyActorDefs.ProfileViewBasic + /** The record data itself. */ value: {} labels?: ComAtprotoLabelDefs.Label[] embeds?: ( diff --git a/packages/api/src/client/types/app/bsky/feed/defs.ts b/packages/api/src/client/types/app/bsky/feed/defs.ts index 82cbfd9951a..856d5356086 100644 --- a/packages/api/src/client/types/app/bsky/feed/defs.ts +++ b/packages/api/src/client/types/app/bsky/feed/defs.ts @@ -45,6 +45,7 @@ export function validatePostView(v: unknown): ValidationResult { return lexicons.validate('app.bsky.feed.defs#postView', v) } +/** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ export interface ViewerState { repost?: string like?: string @@ -218,6 +219,7 @@ export interface GeneratorView { descriptionFacets?: AppBskyRichtextFacet.Main[] avatar?: string likeCount?: number + labels?: ComAtprotoLabelDefs.Label[] viewer?: GeneratorViewerState indexedAt: string [k: string]: unknown diff --git a/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts b/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts index a070dad6ff7..3f498e49514 100644 --- a/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts +++ b/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts @@ -12,6 +12,7 @@ export interface QueryParams { actor: string limit?: number cursor?: string + /** Combinations of post/repost types to include in response. */ filter?: | 'posts_with_replies' | 'posts_no_replies' diff --git a/packages/api/src/client/types/app/bsky/feed/getFeedGenerator.ts b/packages/api/src/client/types/app/bsky/feed/getFeedGenerator.ts index a2f9b405c97..f08c9b59340 100644 --- a/packages/api/src/client/types/app/bsky/feed/getFeedGenerator.ts +++ b/packages/api/src/client/types/app/bsky/feed/getFeedGenerator.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** AT-URI of the feed generator record. */ feed: string } @@ -16,7 +17,9 @@ export type InputSchema = undefined export interface OutputSchema { view: AppBskyFeedDefs.GeneratorView + /** Indicates whether the feed generator service has been online recently, or else seems to be inactive. */ isOnline: boolean + /** Indicates whether the feed generator service is compatible with the record declaration. */ isValid: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/app/bsky/feed/getFeedSkeleton.ts b/packages/api/src/client/types/app/bsky/feed/getFeedSkeleton.ts index 0aa325d7fec..1426469c84d 100644 --- a/packages/api/src/client/types/app/bsky/feed/getFeedSkeleton.ts +++ b/packages/api/src/client/types/app/bsky/feed/getFeedSkeleton.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** Reference to feed generator record describing the specific feed being requested. */ feed: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/feed/getLikes.ts b/packages/api/src/client/types/app/bsky/feed/getLikes.ts index d78047feb6e..9725ef065d9 100644 --- a/packages/api/src/client/types/app/bsky/feed/getLikes.ts +++ b/packages/api/src/client/types/app/bsky/feed/getLikes.ts @@ -9,7 +9,9 @@ import { CID } from 'multiformats/cid' import * as AppBskyActorDefs from '../actor/defs' export interface QueryParams { + /** AT-URI of the subject (eg, a post record). */ uri: string + /** CID of the subject record (aka, specific version of record), to filter likes. */ cid?: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/feed/getListFeed.ts b/packages/api/src/client/types/app/bsky/feed/getListFeed.ts index 511e9526c6d..6b4156ddda9 100644 --- a/packages/api/src/client/types/app/bsky/feed/getListFeed.ts +++ b/packages/api/src/client/types/app/bsky/feed/getListFeed.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** Reference (AT-URI) to the list record. */ list: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/feed/getPostThread.ts b/packages/api/src/client/types/app/bsky/feed/getPostThread.ts index d3865db9ee2..d03ad7de127 100644 --- a/packages/api/src/client/types/app/bsky/feed/getPostThread.ts +++ b/packages/api/src/client/types/app/bsky/feed/getPostThread.ts @@ -9,8 +9,11 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** Reference (AT-URI) to post record. */ uri: string + /** How many levels of reply depth should be included in response. */ depth?: number + /** How many levels of parent (and grandparent, etc) post to include. */ parentHeight?: number } diff --git a/packages/api/src/client/types/app/bsky/feed/getPosts.ts b/packages/api/src/client/types/app/bsky/feed/getPosts.ts index 933919bdcc1..cd932d88047 100644 --- a/packages/api/src/client/types/app/bsky/feed/getPosts.ts +++ b/packages/api/src/client/types/app/bsky/feed/getPosts.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** List of post AT-URIs to return hydrated views for. */ uris: string[] } diff --git a/packages/api/src/client/types/app/bsky/feed/getRepostedBy.ts b/packages/api/src/client/types/app/bsky/feed/getRepostedBy.ts index 30a1a109aaa..d27aa1dec0a 100644 --- a/packages/api/src/client/types/app/bsky/feed/getRepostedBy.ts +++ b/packages/api/src/client/types/app/bsky/feed/getRepostedBy.ts @@ -9,7 +9,9 @@ import { CID } from 'multiformats/cid' import * as AppBskyActorDefs from '../actor/defs' export interface QueryParams { + /** Reference (AT-URI) of post record */ uri: string + /** If supplied, filters to reposts of specific version (by CID) of the post record. */ cid?: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/feed/getTimeline.ts b/packages/api/src/client/types/app/bsky/feed/getTimeline.ts index 6d8dacff99a..5ab2c7c4b1f 100644 --- a/packages/api/src/client/types/app/bsky/feed/getTimeline.ts +++ b/packages/api/src/client/types/app/bsky/feed/getTimeline.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyFeedDefs from './defs' export interface QueryParams { + /** Variant 'algorithm' for timeline. Implementation-specific. NOTE: most feed flexibility has been moved to feed generator mechanism. */ algorithm?: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/feed/post.ts b/packages/api/src/client/types/app/bsky/feed/post.ts index a3299e19035..0de5192af77 100644 --- a/packages/api/src/client/types/app/bsky/feed/post.ts +++ b/packages/api/src/client/types/app/bsky/feed/post.ts @@ -14,9 +14,11 @@ import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' export interface Record { + /** The primary post content. May be an empty string, if there are embeds. */ text: string - /** Deprecated: replaced by app.bsky.richtext.facet. */ + /** DEPRECATED: replaced by app.bsky.richtext.facet. */ entities?: Entity[] + /** Annotations of text (mentions, URLs, hashtags, etc) */ facets?: AppBskyRichtextFacet.Main[] reply?: ReplyRef embed?: @@ -25,12 +27,14 @@ export interface Record { | AppBskyEmbedRecord.Main | AppBskyEmbedRecordWithMedia.Main | { $type: string; [k: string]: unknown } + /** Indicates human language of post primary text content. */ langs?: string[] labels?: | ComAtprotoLabelDefs.SelfLabels | { $type: string; [k: string]: unknown } - /** Additional non-inline tags describing this post. */ + /** Additional hashtags, in addition to any included in post text and facets. */ tags?: string[] + /** Client-declared timestamp when this post was originally created. */ createdAt: string [k: string]: unknown } diff --git a/packages/api/src/client/types/app/bsky/feed/threadgate.ts b/packages/api/src/client/types/app/bsky/feed/threadgate.ts index a1afec85673..cc8c05a78ec 100644 --- a/packages/api/src/client/types/app/bsky/feed/threadgate.ts +++ b/packages/api/src/client/types/app/bsky/feed/threadgate.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' export interface Record { + /** Reference (AT-URI) to the post record. */ post: string allow?: ( | MentionRule diff --git a/packages/api/src/client/types/app/bsky/graph/block.ts b/packages/api/src/client/types/app/bsky/graph/block.ts index c35258d979a..f2455fc08a2 100644 --- a/packages/api/src/client/types/app/bsky/graph/block.ts +++ b/packages/api/src/client/types/app/bsky/graph/block.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' export interface Record { + /** DID of the account to be blocked. */ subject: string createdAt: string [k: string]: unknown diff --git a/packages/api/src/client/types/app/bsky/graph/defs.ts b/packages/api/src/client/types/app/bsky/graph/defs.ts index 0580b9bb158..78d3ac24d62 100644 --- a/packages/api/src/client/types/app/bsky/graph/defs.ts +++ b/packages/api/src/client/types/app/bsky/graph/defs.ts @@ -5,6 +5,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' import * as AppBskyActorDefs from '../actor/defs' import * as AppBskyRichtextFacet from '../richtext/facet' @@ -14,6 +15,7 @@ export interface ListViewBasic { name: string purpose: ListPurpose avatar?: string + labels?: ComAtprotoLabelDefs.Label[] viewer?: ListViewerState indexedAt?: string [k: string]: unknown @@ -40,6 +42,7 @@ export interface ListView { description?: string descriptionFacets?: AppBskyRichtextFacet.Main[] avatar?: string + labels?: ComAtprotoLabelDefs.Label[] viewer?: ListViewerState indexedAt: string [k: string]: unknown diff --git a/packages/api/src/client/types/app/bsky/graph/getList.ts b/packages/api/src/client/types/app/bsky/graph/getList.ts index 13ebd9d3ae6..36c4cf0aa86 100644 --- a/packages/api/src/client/types/app/bsky/graph/getList.ts +++ b/packages/api/src/client/types/app/bsky/graph/getList.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyGraphDefs from './defs' export interface QueryParams { + /** Reference (AT-URI) of the list record to hydrate. */ list: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/graph/getLists.ts b/packages/api/src/client/types/app/bsky/graph/getLists.ts index 80a7edfb759..644aeea3b4b 100644 --- a/packages/api/src/client/types/app/bsky/graph/getLists.ts +++ b/packages/api/src/client/types/app/bsky/graph/getLists.ts @@ -9,6 +9,7 @@ import { CID } from 'multiformats/cid' import * as AppBskyGraphDefs from './defs' export interface QueryParams { + /** The account (actor) to enumerate lists from. */ actor: string limit?: number cursor?: string diff --git a/packages/api/src/client/types/app/bsky/graph/getRelationships.ts b/packages/api/src/client/types/app/bsky/graph/getRelationships.ts index 5fce53f635c..9aa58ad2699 100644 --- a/packages/api/src/client/types/app/bsky/graph/getRelationships.ts +++ b/packages/api/src/client/types/app/bsky/graph/getRelationships.ts @@ -9,7 +9,9 @@ import { CID } from 'multiformats/cid' import * as AppBskyGraphDefs from './defs' export interface QueryParams { + /** Primary account requesting relationships for. */ actor: string + /** List of 'other' accounts to be related back to the primary. */ others?: string[] } diff --git a/packages/api/src/client/types/app/bsky/graph/list.ts b/packages/api/src/client/types/app/bsky/graph/list.ts index 4fe6dd8ed8b..fec652ccb12 100644 --- a/packages/api/src/client/types/app/bsky/graph/list.ts +++ b/packages/api/src/client/types/app/bsky/graph/list.ts @@ -11,6 +11,7 @@ import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' export interface Record { purpose: AppBskyGraphDefs.ListPurpose + /** Display name for list; can not be empty. */ name: string description?: string descriptionFacets?: AppBskyRichtextFacet.Main[] diff --git a/packages/api/src/client/types/app/bsky/graph/listblock.ts b/packages/api/src/client/types/app/bsky/graph/listblock.ts index 770dfbb0775..e0f02be268f 100644 --- a/packages/api/src/client/types/app/bsky/graph/listblock.ts +++ b/packages/api/src/client/types/app/bsky/graph/listblock.ts @@ -7,6 +7,7 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' export interface Record { + /** Reference (AT-URI) to the mod list record. */ subject: string createdAt: string [k: string]: unknown diff --git a/packages/api/src/client/types/app/bsky/graph/listitem.ts b/packages/api/src/client/types/app/bsky/graph/listitem.ts index 5059ef69c10..d4fb5631e84 100644 --- a/packages/api/src/client/types/app/bsky/graph/listitem.ts +++ b/packages/api/src/client/types/app/bsky/graph/listitem.ts @@ -7,7 +7,9 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' export interface Record { + /** The account which is included on the list. */ subject: string + /** Reference (AT-URI) to the list record (app.bsky.graph.list). */ list: string createdAt: string [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/app/bsky/richtext/facet.ts b/packages/api/src/client/types/app/bsky/richtext/facet.ts index 96573bb06fe..836136b7dac 100644 --- a/packages/api/src/client/types/app/bsky/richtext/facet.ts +++ b/packages/api/src/client/types/app/bsky/richtext/facet.ts @@ -6,6 +6,7 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +/** Annotation of a sub-string within rich text. */ export interface Main { index: ByteSlice features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[] @@ -25,7 +26,7 @@ export function validateMain(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#main', v) } -/** A facet feature for actor mentions. */ +/** Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID. */ export interface Mention { did: string [k: string]: unknown @@ -43,7 +44,7 @@ export function validateMention(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#mention', v) } -/** A facet feature for links. */ +/** Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. */ export interface Link { uri: string [k: string]: unknown @@ -61,7 +62,7 @@ export function validateLink(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#link', v) } -/** A hashtag. */ +/** Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). */ export interface Tag { tag: string [k: string]: unknown @@ -77,7 +78,7 @@ export function validateTag(v: unknown): ValidationResult { return lexicons.validate('app.bsky.richtext.facet#tag', v) } -/** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */ +/** Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. */ export interface ByteSlice { byteStart: number byteEnd: number diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index da154f8a845..b732f2b05d8 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -5,10 +5,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoRepoStrongRef from '../repo/strongRef' -import * as ComAtprotoModerationDefs from '../moderation/defs' import * as ComAtprotoServerDefs from '../server/defs' -import * as ComAtprotoLabelDefs from '../label/defs' export interface StatusAttr { applied: boolean @@ -28,229 +25,6 @@ export function validateStatusAttr(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#statusAttr', v) } -export interface ModEventView { - id: number - event: - | ModEventTakedown - | ModEventReverseTakedown - | ModEventComment - | ModEventReport - | ModEventLabel - | ModEventAcknowledge - | ModEventEscalate - | ModEventMute - | ModEventEmail - | { $type: string; [k: string]: unknown } - subject: - | RepoRef - | ComAtprotoRepoStrongRef.Main - | { $type: string; [k: string]: unknown } - subjectBlobCids: string[] - createdBy: string - createdAt: string - creatorHandle?: string - subjectHandle?: string - [k: string]: unknown -} - -export function isModEventView(v: unknown): v is ModEventView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventView' - ) -} - -export function validateModEventView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventView', v) -} - -export interface ModEventViewDetail { - id: number - event: - | ModEventTakedown - | ModEventReverseTakedown - | ModEventComment - | ModEventReport - | ModEventLabel - | ModEventAcknowledge - | ModEventEscalate - | ModEventMute - | ModEventResolveAppeal - | { $type: string; [k: string]: unknown } - subject: - | RepoView - | RepoViewNotFound - | RecordView - | RecordViewNotFound - | { $type: string; [k: string]: unknown } - subjectBlobs: BlobView[] - createdBy: string - createdAt: string - [k: string]: unknown -} - -export function isModEventViewDetail(v: unknown): v is ModEventViewDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventViewDetail' - ) -} - -export function validateModEventViewDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventViewDetail', v) -} - -export interface ReportView { - id: number - reasonType: ComAtprotoModerationDefs.ReasonType - comment?: string - subjectRepoHandle?: string - subject: - | RepoRef - | ComAtprotoRepoStrongRef.Main - | { $type: string; [k: string]: unknown } - reportedBy: string - createdAt: string - resolvedByActionIds: number[] - [k: string]: unknown -} - -export function isReportView(v: unknown): v is ReportView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#reportView' - ) -} - -export function validateReportView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#reportView', v) -} - -export interface SubjectStatusView { - id: number - subject: - | RepoRef - | ComAtprotoRepoStrongRef.Main - | { $type: string; [k: string]: unknown } - subjectBlobCids?: string[] - subjectRepoHandle?: string - /** Timestamp referencing when the last update was made to the moderation status of the subject */ - updatedAt: string - /** Timestamp referencing the first moderation status impacting event was emitted on the subject */ - createdAt: string - reviewState: SubjectReviewState - /** Sticky comment on the subject. */ - comment?: string - muteUntil?: string - lastReviewedBy?: string - lastReviewedAt?: string - lastReportedAt?: string - /** Timestamp referencing when the author of the subject appealed a moderation action */ - lastAppealedAt?: string - takendown?: boolean - /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ - appealed?: boolean - suspendUntil?: string - [k: string]: unknown -} - -export function isSubjectStatusView(v: unknown): v is SubjectStatusView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#subjectStatusView' - ) -} - -export function validateSubjectStatusView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#subjectStatusView', v) -} - -export interface ReportViewDetail { - id: number - reasonType: ComAtprotoModerationDefs.ReasonType - comment?: string - subject: - | RepoView - | RepoViewNotFound - | RecordView - | RecordViewNotFound - | { $type: string; [k: string]: unknown } - subjectStatus?: SubjectStatusView - reportedBy: string - createdAt: string - resolvedByActions: ModEventView[] - [k: string]: unknown -} - -export function isReportViewDetail(v: unknown): v is ReportViewDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#reportViewDetail' - ) -} - -export function validateReportViewDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#reportViewDetail', v) -} - -export interface RepoView { - did: string - handle: string - email?: string - relatedRecords: {}[] - indexedAt: string - moderation: Moderation - invitedBy?: ComAtprotoServerDefs.InviteCode - invitesDisabled?: boolean - inviteNote?: string - [k: string]: unknown -} - -export function isRepoView(v: unknown): v is RepoView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#repoView' - ) -} - -export function validateRepoView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#repoView', v) -} - -export interface RepoViewDetail { - did: string - handle: string - email?: string - relatedRecords: {}[] - indexedAt: string - moderation: ModerationDetail - labels?: ComAtprotoLabelDefs.Label[] - invitedBy?: ComAtprotoServerDefs.InviteCode - invites?: ComAtprotoServerDefs.InviteCode[] - invitesDisabled?: boolean - inviteNote?: string - emailConfirmedAt?: string - [k: string]: unknown -} - -export function isRepoViewDetail(v: unknown): v is RepoViewDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#repoViewDetail' - ) -} - -export function validateRepoViewDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#repoViewDetail', v) -} - export interface AccountView { did: string handle: string @@ -277,23 +51,6 @@ export function validateAccountView(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#accountView', v) } -export interface RepoViewNotFound { - did: string - [k: string]: unknown -} - -export function isRepoViewNotFound(v: unknown): v is RepoViewNotFound { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#repoViewNotFound' - ) -} - -export function validateRepoViewNotFound(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#repoViewNotFound', v) -} - export interface RepoRef { did: string [k: string]: unknown @@ -329,426 +86,3 @@ export function isRepoBlobRef(v: unknown): v is RepoBlobRef { export function validateRepoBlobRef(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoBlobRef', v) } - -export interface RecordView { - uri: string - cid: string - value: {} - blobCids: string[] - indexedAt: string - moderation: Moderation - repo: RepoView - [k: string]: unknown -} - -export function isRecordView(v: unknown): v is RecordView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#recordView' - ) -} - -export function validateRecordView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#recordView', v) -} - -export interface RecordViewDetail { - uri: string - cid: string - value: {} - blobs: BlobView[] - labels?: ComAtprotoLabelDefs.Label[] - indexedAt: string - moderation: ModerationDetail - repo: RepoView - [k: string]: unknown -} - -export function isRecordViewDetail(v: unknown): v is RecordViewDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#recordViewDetail' - ) -} - -export function validateRecordViewDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#recordViewDetail', v) -} - -export interface RecordViewNotFound { - uri: string - [k: string]: unknown -} - -export function isRecordViewNotFound(v: unknown): v is RecordViewNotFound { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#recordViewNotFound' - ) -} - -export function validateRecordViewNotFound(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#recordViewNotFound', v) -} - -export interface Moderation { - subjectStatus?: SubjectStatusView - [k: string]: unknown -} - -export function isModeration(v: unknown): v is Moderation { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#moderation' - ) -} - -export function validateModeration(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#moderation', v) -} - -export interface ModerationDetail { - subjectStatus?: SubjectStatusView - [k: string]: unknown -} - -export function isModerationDetail(v: unknown): v is ModerationDetail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#moderationDetail' - ) -} - -export function validateModerationDetail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#moderationDetail', v) -} - -export interface BlobView { - cid: string - mimeType: string - size: number - createdAt: string - details?: - | ImageDetails - | VideoDetails - | { $type: string; [k: string]: unknown } - moderation?: Moderation - [k: string]: unknown -} - -export function isBlobView(v: unknown): v is BlobView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#blobView' - ) -} - -export function validateBlobView(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#blobView', v) -} - -export interface ImageDetails { - width: number - height: number - [k: string]: unknown -} - -export function isImageDetails(v: unknown): v is ImageDetails { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#imageDetails' - ) -} - -export function validateImageDetails(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#imageDetails', v) -} - -export interface VideoDetails { - width: number - height: number - length: number - [k: string]: unknown -} - -export function isVideoDetails(v: unknown): v is VideoDetails { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#videoDetails' - ) -} - -export function validateVideoDetails(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#videoDetails', v) -} - -export type SubjectReviewState = - | 'lex:com.atproto.admin.defs#reviewOpen' - | 'lex:com.atproto.admin.defs#reviewEscalated' - | 'lex:com.atproto.admin.defs#reviewClosed' - | (string & {}) - -/** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ -export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' -/** Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator */ -export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' -/** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ -export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' - -/** Take down a subject permanently or temporarily */ -export interface ModEventTakedown { - comment?: string - /** Indicates how long the takedown should be in effect before automatically expiring. */ - durationInHours?: number - [k: string]: unknown -} - -export function isModEventTakedown(v: unknown): v is ModEventTakedown { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventTakedown' - ) -} - -export function validateModEventTakedown(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventTakedown', v) -} - -/** Revert take down action on a subject */ -export interface ModEventReverseTakedown { - /** Describe reasoning behind the reversal. */ - comment?: string - [k: string]: unknown -} - -export function isModEventReverseTakedown( - v: unknown, -): v is ModEventReverseTakedown { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventReverseTakedown' - ) -} - -export function validateModEventReverseTakedown(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) -} - -/** Resolve appeal on a subject */ -export interface ModEventResolveAppeal { - /** Describe resolution. */ - comment?: string - [k: string]: unknown -} - -export function isModEventResolveAppeal( - v: unknown, -): v is ModEventResolveAppeal { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' - ) -} - -export function validateModEventResolveAppeal(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) -} - -/** Add a comment to a subject */ -export interface ModEventComment { - comment: string - /** Make the comment persistent on the subject */ - sticky?: boolean - [k: string]: unknown -} - -export function isModEventComment(v: unknown): v is ModEventComment { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventComment' - ) -} - -export function validateModEventComment(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventComment', v) -} - -/** Report a subject */ -export interface ModEventReport { - comment?: string - reportType: ComAtprotoModerationDefs.ReasonType - [k: string]: unknown -} - -export function isModEventReport(v: unknown): v is ModEventReport { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventReport' - ) -} - -export function validateModEventReport(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventReport', v) -} - -/** Apply/Negate labels on a subject */ -export interface ModEventLabel { - comment?: string - createLabelVals: string[] - negateLabelVals: string[] - [k: string]: unknown -} - -export function isModEventLabel(v: unknown): v is ModEventLabel { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventLabel' - ) -} - -export function validateModEventLabel(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventLabel', v) -} - -export interface ModEventAcknowledge { - comment?: string - [k: string]: unknown -} - -export function isModEventAcknowledge(v: unknown): v is ModEventAcknowledge { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventAcknowledge' - ) -} - -export function validateModEventAcknowledge(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventAcknowledge', v) -} - -export interface ModEventEscalate { - comment?: string - [k: string]: unknown -} - -export function isModEventEscalate(v: unknown): v is ModEventEscalate { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventEscalate' - ) -} - -export function validateModEventEscalate(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventEscalate', v) -} - -/** Mute incoming reports on a subject */ -export interface ModEventMute { - comment?: string - /** Indicates how long the subject should remain muted. */ - durationInHours: number - [k: string]: unknown -} - -export function isModEventMute(v: unknown): v is ModEventMute { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventMute' - ) -} - -export function validateModEventMute(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventMute', v) -} - -/** Unmute action on a subject */ -export interface ModEventUnmute { - /** Describe reasoning behind the reversal. */ - comment?: string - [k: string]: unknown -} - -export function isModEventUnmute(v: unknown): v is ModEventUnmute { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventUnmute' - ) -} - -export function validateModEventUnmute(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventUnmute', v) -} - -/** Keep a log of outgoing email to a user */ -export interface ModEventEmail { - /** The subject line of the email sent to the user. */ - subjectLine: string - /** Additional comment about the outgoing comm. */ - comment?: string - [k: string]: unknown -} - -export function isModEventEmail(v: unknown): v is ModEventEmail { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#modEventEmail' - ) -} - -export function validateModEventEmail(v: unknown): ValidationResult { - return lexicons.validate('com.atproto.admin.defs#modEventEmail', v) -} - -export interface CommunicationTemplateView { - id: string - /** Name of the template. */ - name: string - /** Content of the template, can contain markdown and variable placeholders. */ - subject?: string - /** Subject of the message, used in emails. */ - contentMarkdown: string - disabled: boolean - /** DID of the user who last updated the template. */ - lastUpdatedBy: string - createdAt: string - updatedAt: string - [k: string]: unknown -} - -export function isCommunicationTemplateView( - v: unknown, -): v is CommunicationTemplateView { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'com.atproto.admin.defs#communicationTemplateView' - ) -} - -export function validateCommunicationTemplateView( - v: unknown, -): ValidationResult { - return lexicons.validate( - 'com.atproto.admin.defs#communicationTemplateView', - v, - ) -} diff --git a/packages/api/src/client/types/com/atproto/temp/pushBlob.ts b/packages/api/src/client/types/com/atproto/admin/updateAccountPassword.ts similarity index 79% rename from packages/api/src/client/types/com/atproto/temp/pushBlob.ts rename to packages/api/src/client/types/com/atproto/admin/updateAccountPassword.ts index 32165bc8014..99ef3881c37 100644 --- a/packages/api/src/client/types/com/atproto/temp/pushBlob.ts +++ b/packages/api/src/client/types/com/atproto/admin/updateAccountPassword.ts @@ -7,17 +7,18 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -export interface QueryParams { - /** The DID of the repo. */ +export interface QueryParams {} + +export interface InputSchema { did: string + password: string + [k: string]: unknown } -export type InputSchema = string | Uint8Array - export interface CallOptions { headers?: Headers qp?: QueryParams - encoding: string + encoding: 'application/json' } export interface Response { diff --git a/packages/api/src/client/types/com/atproto/identity/getRecommendedDidCredentials.ts b/packages/api/src/client/types/com/atproto/identity/getRecommendedDidCredentials.ts new file mode 100644 index 00000000000..dfa143e4ab3 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/identity/getRecommendedDidCredentials.ts @@ -0,0 +1,37 @@ +/** + * 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' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + /** Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs. */ + rotationKeys?: string[] + alsoKnownAs?: string[] + verificationMethods?: {} + services?: {} + [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/com/atproto/identity/requestPlcOperationSignature.ts b/packages/api/src/client/types/com/atproto/identity/requestPlcOperationSignature.ts new file mode 100644 index 00000000000..ef2ed1ac47c --- /dev/null +++ b/packages/api/src/client/types/com/atproto/identity/requestPlcOperationSignature.ts @@ -0,0 +1,28 @@ +/** + * 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' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/identity/signPlcOperation.ts b/packages/api/src/client/types/com/atproto/identity/signPlcOperation.ts new file mode 100644 index 00000000000..3060c1e3f4d --- /dev/null +++ b/packages/api/src/client/types/com/atproto/identity/signPlcOperation.ts @@ -0,0 +1,44 @@ +/** + * 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' + +export interface QueryParams {} + +export interface InputSchema { + /** A token received through com.atproto.identity.requestPlcOperationSignature */ + token?: string + rotationKeys?: string[] + alsoKnownAs?: string[] + verificationMethods?: {} + services?: {} + [k: string]: unknown +} + +export interface OutputSchema { + /** A signed DID PLC operation. */ + operation: {} + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +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/com/atproto/identity/submitPlcOperation.ts b/packages/api/src/client/types/com/atproto/identity/submitPlcOperation.ts new file mode 100644 index 00000000000..4ba52f74fc7 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/identity/submitPlcOperation.ts @@ -0,0 +1,32 @@ +/** + * 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' + +export interface QueryParams {} + +export interface InputSchema { + operation: {} + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/identity/updateHandle.ts b/packages/api/src/client/types/com/atproto/identity/updateHandle.ts index 4c01e105c28..2bd2c4c9d6a 100644 --- a/packages/api/src/client/types/com/atproto/identity/updateHandle.ts +++ b/packages/api/src/client/types/com/atproto/identity/updateHandle.ts @@ -10,6 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { + /** The new handle. */ handle: string [k: string]: unknown } 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..34009a39b03 100644 --- a/packages/api/src/client/types/com/atproto/label/defs.ts +++ b/packages/api/src/client/types/com/atproto/label/defs.ts @@ -8,6 +8,8 @@ import { CID } from 'multiformats/cid' /** Metadata tag on an atproto resource (eg, repo or record). */ export interface Label { + /** The AT Protocol version of the label object. */ + ver?: number /** DID of the actor who created this label. */ src: string /** AT URI of the record, repository (account), or other resource that this label applies to. */ @@ -20,6 +22,10 @@ export interface Label { neg?: boolean /** Timestamp when this label was created. */ cts: string + /** Timestamp at which this label expires (no longer applies). */ + exp?: string + /** Signature of dag-cbor encoded label. */ + sig?: Uint8Array [k: string]: unknown } @@ -71,3 +77,75 @@ 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 & {}) + /** The default setting for this label. */ + defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) + /** Does the user need to have adult content enabled in order to configure this label? */ + adultOnly?: boolean + locales: LabelValueDefinitionStrings[] + [k: string]: unknown +} + +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/client/types/com/atproto/moderation/createReport.ts b/packages/api/src/client/types/com/atproto/moderation/createReport.ts index 826b32ff67c..7bf3cc1a380 100644 --- a/packages/api/src/client/types/com/atproto/moderation/createReport.ts +++ b/packages/api/src/client/types/com/atproto/moderation/createReport.ts @@ -14,6 +14,7 @@ export interface QueryParams {} export interface InputSchema { reasonType: ComAtprotoModerationDefs.ReasonType + /** Additional context about the content and violation. */ reason?: string subject: | ComAtprotoAdminDefs.RepoRef diff --git a/packages/api/src/client/types/com/atproto/repo/applyWrites.ts b/packages/api/src/client/types/com/atproto/repo/applyWrites.ts index f4a8a269201..df35ec7dcbf 100644 --- a/packages/api/src/client/types/com/atproto/repo/applyWrites.ts +++ b/packages/api/src/client/types/com/atproto/repo/applyWrites.ts @@ -10,11 +10,12 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** The handle or DID of the repo. */ + /** The handle or DID of the repo (aka, current account). */ repo: string - /** Flag for validating the records. */ + /** Can be set to 'false' to skip Lexicon schema validation of record data, for all operations. */ validate?: boolean writes: (Create | Update | Delete)[] + /** If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations. */ swapCommit?: string [k: string]: unknown } @@ -43,7 +44,7 @@ export function toKnownErr(e: any) { return e } -/** Create a new record. */ +/** Operation which creates a new record. */ export interface Create { collection: string rkey?: string @@ -63,7 +64,7 @@ export function validateCreate(v: unknown): ValidationResult { return lexicons.validate('com.atproto.repo.applyWrites#create', v) } -/** Update an existing record. */ +/** Operation which updates an existing record. */ export interface Update { collection: string rkey: string @@ -83,7 +84,7 @@ export function validateUpdate(v: unknown): ValidationResult { return lexicons.validate('com.atproto.repo.applyWrites#update', v) } -/** Delete an existing record. */ +/** Operation which deletes an existing record. */ export interface Delete { collection: string rkey: string diff --git a/packages/api/src/client/types/com/atproto/repo/createRecord.ts b/packages/api/src/client/types/com/atproto/repo/createRecord.ts index 2056778c71c..6b13f67db7f 100644 --- a/packages/api/src/client/types/com/atproto/repo/createRecord.ts +++ b/packages/api/src/client/types/com/atproto/repo/createRecord.ts @@ -10,15 +10,15 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** The handle or DID of the repo. */ + /** The handle or DID of the repo (aka, current account). */ repo: string /** The NSID of the record collection. */ collection: string - /** The key of the record. */ + /** The Record Key. */ rkey?: string - /** Flag for validating the record. */ + /** Can be set to 'false' to skip Lexicon schema validation of record data. */ validate?: boolean - /** The record to create. */ + /** The record itself. Must contain a $type field. */ record: {} /** Compare and swap with the previous commit by CID. */ swapCommit?: string diff --git a/packages/api/src/client/types/com/atproto/repo/deleteRecord.ts b/packages/api/src/client/types/com/atproto/repo/deleteRecord.ts index 5bf9237abbb..54109b62f31 100644 --- a/packages/api/src/client/types/com/atproto/repo/deleteRecord.ts +++ b/packages/api/src/client/types/com/atproto/repo/deleteRecord.ts @@ -10,11 +10,11 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** The handle or DID of the repo. */ + /** The handle or DID of the repo (aka, current account). */ repo: string /** The NSID of the record collection. */ collection: string - /** The key of the record. */ + /** The Record Key. */ rkey: string /** Compare and swap with the previous record by CID. */ swapRecord?: string diff --git a/packages/api/src/client/types/com/atproto/repo/describeRepo.ts b/packages/api/src/client/types/com/atproto/repo/describeRepo.ts index e6ecedb3297..f17a8410782 100644 --- a/packages/api/src/client/types/com/atproto/repo/describeRepo.ts +++ b/packages/api/src/client/types/com/atproto/repo/describeRepo.ts @@ -17,8 +17,11 @@ export type InputSchema = undefined export interface OutputSchema { handle: string did: string + /** The complete DID document for this account. */ didDoc: {} + /** List of all the collections (NSIDs) for which this repo contains at least one record. */ collections: string[] + /** Indicates if handle is currently valid (resolves bi-directionally) */ handleIsCorrect: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/repo/getRecord.ts b/packages/api/src/client/types/com/atproto/repo/getRecord.ts index 56338d016ee..a6d2bd39e8c 100644 --- a/packages/api/src/client/types/com/atproto/repo/getRecord.ts +++ b/packages/api/src/client/types/com/atproto/repo/getRecord.ts @@ -12,7 +12,7 @@ export interface QueryParams { repo: string /** The NSID of the record collection. */ collection: string - /** The key of the record. */ + /** The Record Key. */ rkey: string /** The CID of the version of the record. If not specified, then return the most recent version. */ cid?: string diff --git a/packages/api/src/client/types/com/atproto/temp/importRepo.ts b/packages/api/src/client/types/com/atproto/repo/importRepo.ts similarity index 86% rename from packages/api/src/client/types/com/atproto/temp/importRepo.ts rename to packages/api/src/client/types/com/atproto/repo/importRepo.ts index 6f9f99f2b9d..040cca671bf 100644 --- a/packages/api/src/client/types/com/atproto/temp/importRepo.ts +++ b/packages/api/src/client/types/com/atproto/repo/importRepo.ts @@ -7,10 +7,7 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -export interface QueryParams { - /** The DID of the repo. */ - did: string -} +export interface QueryParams {} export type InputSchema = string | Uint8Array @@ -23,7 +20,6 @@ export interface CallOptions { export interface Response { success: boolean headers: Headers - data: Uint8Array } export function toKnownErr(e: any) { diff --git a/packages/api/src/client/types/com/atproto/admin/queryModerationEvents.ts b/packages/api/src/client/types/com/atproto/repo/listMissingBlobs.ts similarity index 55% rename from packages/api/src/client/types/com/atproto/admin/queryModerationEvents.ts rename to packages/api/src/client/types/com/atproto/repo/listMissingBlobs.ts index ed21c739bcb..b66f617eea7 100644 --- a/packages/api/src/client/types/com/atproto/admin/queryModerationEvents.ts +++ b/packages/api/src/client/types/com/atproto/repo/listMissingBlobs.ts @@ -6,17 +6,8 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' export interface QueryParams { - /** The types of events (fully qualified string in the format of com.atproto.admin#modEvent) to filter by. If not specified, all events are returned. */ - types?: string[] - createdBy?: string - /** Sort direction for the events. Defaults to descending order of created at timestamp. */ - sortDirection?: 'asc' | 'desc' - subject?: string - /** If true, events on all record types (posts, lists, profile etc.) owned by the did are returned */ - includeAllUserRecords?: boolean limit?: number cursor?: string } @@ -25,7 +16,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string - events: ComAtprotoAdminDefs.ModEventView[] + blobs: RecordBlob[] [k: string]: unknown } @@ -44,3 +35,21 @@ export function toKnownErr(e: any) { } return e } + +export interface RecordBlob { + cid: string + recordUri: string + [k: string]: unknown +} + +export function isRecordBlob(v: unknown): v is RecordBlob { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.repo.listMissingBlobs#recordBlob' + ) +} + +export function validateRecordBlob(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.repo.listMissingBlobs#recordBlob', v) +} diff --git a/packages/api/src/client/types/com/atproto/repo/putRecord.ts b/packages/api/src/client/types/com/atproto/repo/putRecord.ts index 269ef759401..7421ee19780 100644 --- a/packages/api/src/client/types/com/atproto/repo/putRecord.ts +++ b/packages/api/src/client/types/com/atproto/repo/putRecord.ts @@ -10,17 +10,17 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** The handle or DID of the repo. */ + /** The handle or DID of the repo (aka, current account). */ repo: string /** The NSID of the record collection. */ collection: string - /** The key of the record. */ + /** The Record Key. */ rkey: string - /** Flag for validating the record. */ + /** Can be set to 'false' to skip Lexicon schema validation of record data. */ validate?: boolean /** The record to write. */ record: {} - /** Compare and swap with the previous record by CID. */ + /** Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation */ swapRecord?: string | null /** Compare and swap with the previous commit by CID. */ swapCommit?: string diff --git a/packages/api/src/client/types/com/atproto/server/activateAccount.ts b/packages/api/src/client/types/com/atproto/server/activateAccount.ts new file mode 100644 index 00000000000..ef2ed1ac47c --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/activateAccount.ts @@ -0,0 +1,28 @@ +/** + * 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' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/checkAccountStatus.ts b/packages/api/src/client/types/com/atproto/server/checkAccountStatus.ts new file mode 100644 index 00000000000..86a942f81f3 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/checkAccountStatus.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' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + activated: boolean + validDid: boolean + repoCommit: string + repoRev: string + repoBlocks: number + indexedRecords: number + privateStateValues: number + expectedBlobs: number + importedBlobs: number + [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/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index b62adf97cb1..5e36eca0ee3 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -11,22 +11,30 @@ export interface QueryParams {} export interface InputSchema { email?: string + /** Requested handle for the account. */ handle: string + /** Pre-existing atproto DID, being imported to a new account. */ did?: string inviteCode?: string verificationCode?: string verificationPhone?: string + /** Initial account password. May need to meet instance-specific password strength requirements. */ password?: string + /** DID PLC rotation key (aka, recovery key) to be included in PLC creation operation. */ recoveryKey?: string + /** A signed DID PLC operation to be submitted as part of importing an existing account to this instance. NOTE: this optional field may be updated when full account migration is implemented. */ plcOp?: {} [k: string]: unknown } +/** Account login session returned on successful account creation. */ export interface OutputSchema { accessJwt: string refreshJwt: string handle: string + /** The DID of the new account. */ did: string + /** Complete DID document. */ didDoc?: {} [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/createAppPassword.ts b/packages/api/src/client/types/com/atproto/server/createAppPassword.ts index d6e9ce3ddf5..8b9a5d53a7c 100644 --- a/packages/api/src/client/types/com/atproto/server/createAppPassword.ts +++ b/packages/api/src/client/types/com/atproto/server/createAppPassword.ts @@ -10,6 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { + /** A short name for the App Password, to help distinguish them. */ name: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/deactivateAccount.ts b/packages/api/src/client/types/com/atproto/server/deactivateAccount.ts new file mode 100644 index 00000000000..c88bd548243 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/deactivateAccount.ts @@ -0,0 +1,33 @@ +/** + * 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' + +export interface QueryParams {} + +export interface InputSchema { + /** A recommendation to server as to how long they should hold onto the deactivated account before deleting. */ + deleteAfter?: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/describeServer.ts b/packages/api/src/client/types/com/atproto/server/describeServer.ts index fb6c9d5c662..610aa12ce5d 100644 --- a/packages/api/src/client/types/com/atproto/server/describeServer.ts +++ b/packages/api/src/client/types/com/atproto/server/describeServer.ts @@ -12,10 +12,15 @@ export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { + /** If true, an invite code must be supplied to create an account on this instance. */ inviteCodeRequired?: boolean + /** If true, a phone verification token must be supplied to create an account on this instance. */ phoneVerificationRequired?: boolean + /** List of domain suffixes that can be used in account handles. */ availableUserDomains: string[] links?: Links + contact?: Contact + did: string [k: string]: unknown } @@ -52,3 +57,20 @@ export function isLinks(v: unknown): v is Links { export function validateLinks(v: unknown): ValidationResult { return lexicons.validate('com.atproto.server.describeServer#links', v) } + +export interface Contact { + email?: string + [k: string]: unknown +} + +export function isContact(v: unknown): v is Contact { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.describeServer#contact' + ) +} + +export function validateContact(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.describeServer#contact', v) +} diff --git a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts index d019ed3fa23..5438cbc96d6 100644 --- a/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts +++ b/packages/api/src/client/types/com/atproto/server/getAccountInviteCodes.ts @@ -10,6 +10,7 @@ import * as ComAtprotoServerDefs from './defs' export interface QueryParams { includeUsed?: boolean + /** Controls whether any new 'earned' but not 'created' invites should be created. */ createAvailable?: boolean } diff --git a/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts b/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts new file mode 100644 index 00000000000..6056960effc --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts @@ -0,0 +1,36 @@ +/** + * 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' + +export interface QueryParams { + /** The DID of the service that the token will be used to authenticate with */ + aud: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + token: string + [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/com/atproto/server/reserveSigningKey.ts b/packages/api/src/client/types/com/atproto/server/reserveSigningKey.ts index f5e515ff5cf..324dee9665a 100644 --- a/packages/api/src/client/types/com/atproto/server/reserveSigningKey.ts +++ b/packages/api/src/client/types/com/atproto/server/reserveSigningKey.ts @@ -10,13 +10,13 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** The did to reserve a new did:key for */ + /** The DID to reserve a key for. */ did?: string [k: string]: unknown } export interface OutputSchema { - /** Public signing key in the form of a did:key. */ + /** The public key for the reserved signing key, in did:key serialization. */ signingKey: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/sync/getBlob.ts b/packages/api/src/client/types/com/atproto/sync/getBlob.ts index 57bc271ce5a..83d8b79ca08 100644 --- a/packages/api/src/client/types/com/atproto/sync/getBlob.ts +++ b/packages/api/src/client/types/com/atproto/sync/getBlob.ts @@ -8,7 +8,7 @@ import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' export interface QueryParams { - /** The DID of the repo. */ + /** The DID of the account. */ did: string /** The CID of the blob to fetch */ cid: string diff --git a/packages/api/src/client/types/com/atproto/sync/getRecord.ts b/packages/api/src/client/types/com/atproto/sync/getRecord.ts index e7bbcf36343..1fc9a94b406 100644 --- a/packages/api/src/client/types/com/atproto/sync/getRecord.ts +++ b/packages/api/src/client/types/com/atproto/sync/getRecord.ts @@ -11,6 +11,7 @@ export interface QueryParams { /** The DID of the repo. */ did: string collection: string + /** Record Key */ rkey: string /** An optional past commit CID. */ commit?: string diff --git a/packages/api/src/client/types/com/atproto/sync/getRepo.ts b/packages/api/src/client/types/com/atproto/sync/getRepo.ts index 0a45536779e..53e0883d74e 100644 --- a/packages/api/src/client/types/com/atproto/sync/getRepo.ts +++ b/packages/api/src/client/types/com/atproto/sync/getRepo.ts @@ -10,7 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams { /** The DID of the repo. */ did: string - /** The revision of the repo to catch up from. */ + /** The revision ('rev') of the repo to create a diff from. */ since?: string } diff --git a/packages/api/src/client/types/com/atproto/sync/listRepos.ts b/packages/api/src/client/types/com/atproto/sync/listRepos.ts index 669dba37e85..eccf796acb6 100644 --- a/packages/api/src/client/types/com/atproto/sync/listRepos.ts +++ b/packages/api/src/client/types/com/atproto/sync/listRepos.ts @@ -38,6 +38,7 @@ export function toKnownErr(e: any) { export interface Repo { did: string + /** Current repo commit CID */ head: string rev: string [k: string]: unknown diff --git a/packages/api/src/client/types/com/atproto/sync/notifyOfUpdate.ts b/packages/api/src/client/types/com/atproto/sync/notifyOfUpdate.ts index 2b098982684..f53e4a55385 100644 --- a/packages/api/src/client/types/com/atproto/sync/notifyOfUpdate.ts +++ b/packages/api/src/client/types/com/atproto/sync/notifyOfUpdate.ts @@ -10,7 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** Hostname of the service that is notifying of update. */ + /** Hostname of the current service (usually a PDS) that is notifying of update. */ hostname: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/sync/requestCrawl.ts b/packages/api/src/client/types/com/atproto/sync/requestCrawl.ts index c07330a6fb1..089eb84e089 100644 --- a/packages/api/src/client/types/com/atproto/sync/requestCrawl.ts +++ b/packages/api/src/client/types/com/atproto/sync/requestCrawl.ts @@ -10,7 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - /** Hostname of the service that is requesting to be crawled. */ + /** Hostname of the current service (eg, PDS) that is requesting to be crawled. */ hostname: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/sync/subscribeRepos.ts b/packages/api/src/client/types/com/atproto/sync/subscribeRepos.ts index a4fec035874..f4a362f755f 100644 --- a/packages/api/src/client/types/com/atproto/sync/subscribeRepos.ts +++ b/packages/api/src/client/types/com/atproto/sync/subscribeRepos.ts @@ -7,21 +7,29 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' +/** Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature. */ export interface Commit { + /** The stream sequence number of this message. */ seq: number + /** DEPRECATED -- unused */ rebase: boolean + /** Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data. */ tooBig: boolean + /** The repo this event comes from. */ repo: string + /** Repo commit object CID. */ commit: CID + /** DEPRECATED -- unused. WARNING -- nullable and optional; stick with optional to ensure golang interoperability. */ prev?: CID | null - /** The rev of the emitted commit. */ + /** The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event. */ rev: string - /** The rev of the last emitted commit from this repo. */ + /** The rev of the last emitted commit from this repo (if any). */ since: string | null - /** CAR file containing relevant blocks. */ + /** CAR file containing relevant blocks, as a diff since the previous repo state. */ blocks: Uint8Array ops: RepoOp[] blobs: CID[] + /** Timestamp of when this message was originally broadcast. */ time: string [k: string]: unknown } @@ -38,6 +46,27 @@ export function validateCommit(v: unknown): ValidationResult { return lexicons.validate('com.atproto.sync.subscribeRepos#commit', v) } +/** Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache. */ +export interface Identity { + seq: number + did: string + time: string + [k: string]: unknown +} + +export function isIdentity(v: unknown): v is Identity { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.sync.subscribeRepos#identity' + ) +} + +export function validateIdentity(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.sync.subscribeRepos#identity', v) +} + +/** Represents an update of the account's handle, or transition to/from invalid state. NOTE: Will be deprecated in favor of #identity. */ export interface Handle { seq: number did: string @@ -58,6 +87,7 @@ export function validateHandle(v: unknown): ValidationResult { return lexicons.validate('com.atproto.sync.subscribeRepos#handle', v) } +/** Represents an account moving from one PDS instance to another. NOTE: not implemented; account migration uses #identity instead */ export interface Migrate { seq: number did: string @@ -78,6 +108,7 @@ export function validateMigrate(v: unknown): ValidationResult { return lexicons.validate('com.atproto.sync.subscribeRepos#migrate', v) } +/** Indicates that an account has been deleted. NOTE: may be deprecated in favor of #identity or a future #account event */ export interface Tombstone { seq: number did: string @@ -115,10 +146,11 @@ export function validateInfo(v: unknown): ValidationResult { return lexicons.validate('com.atproto.sync.subscribeRepos#info', v) } -/** A repo operation, ie a write of a single record. For creates and updates, CID is the record's CID as of this operation. For deletes, it's null. */ +/** A repo operation, ie a mutation of a single record. */ export interface RepoOp { action: 'create' | 'update' | 'delete' | (string & {}) path: string + /** For creates and updates, the new record CID. For deletions, null. */ cid: CID | null [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/temp/transferAccount.ts b/packages/api/src/client/types/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 7ae16c01290..00000000000 --- a/packages/api/src/client/types/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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' - -export interface QueryParams {} - -export interface InputSchema { - handle: string - did: string - plcOp: {} - [k: string]: unknown -} - -export interface OutputSchema { - accessJwt: string - refreshJwt: string - handle: string - did: string - [k: string]: unknown -} - -export interface CallOptions { - headers?: Headers - qp?: QueryParams - encoding: 'application/json' -} - -export interface Response { - success: boolean - headers: Headers - data: OutputSchema -} - -export class InvalidHandleError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class InvalidPasswordError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class InvalidInviteCodeError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class HandleNotAvailableError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class UnsupportedDomainError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class UnresolvableDidError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class IncompatibleDidDocError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export function toKnownErr(e: any) { - if (e instanceof XRPCError) { - if (e.error === 'InvalidHandle') return new InvalidHandleError(e) - if (e.error === 'InvalidPassword') return new InvalidPasswordError(e) - if (e.error === 'InvalidInviteCode') return new InvalidInviteCodeError(e) - if (e.error === 'HandleNotAvailable') return new HandleNotAvailableError(e) - if (e.error === 'UnsupportedDomain') return new UnsupportedDomainError(e) - if (e.error === 'UnresolvableDid') return new UnresolvableDidError(e) - if (e.error === 'IncompatibleDidDoc') return new IncompatibleDidDocError(e) - } - return e -} diff --git a/packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts b/packages/api/src/client/types/tools/ozone/communication/createTemplate.ts similarity index 88% rename from packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts rename to packages/api/src/client/types/tools/ozone/communication/createTemplate.ts index 2efe4c22e1e..0aba76123c6 100644 --- a/packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts +++ b/packages/api/src/client/types/tools/ozone/communication/createTemplate.ts @@ -6,7 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneCommunicationDefs from './defs' export interface QueryParams {} @@ -22,7 +22,7 @@ export interface InputSchema { [k: string]: unknown } -export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView +export type OutputSchema = ToolsOzoneCommunicationDefs.TemplateView export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/tools/ozone/communication/defs.ts b/packages/api/src/client/types/tools/ozone/communication/defs.ts new file mode 100644 index 00000000000..9384d63664d --- /dev/null +++ b/packages/api/src/client/types/tools/ozone/communication/defs.ts @@ -0,0 +1,35 @@ +/** + * 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' + +export interface TemplateView { + id: string + /** Name of the template. */ + name: string + /** Content of the template, can contain markdown and variable placeholders. */ + subject?: string + /** Subject of the message, used in emails. */ + contentMarkdown: string + disabled: boolean + /** DID of the user who last updated the template. */ + lastUpdatedBy: string + createdAt: string + updatedAt: string + [k: string]: unknown +} + +export function isTemplateView(v: unknown): v is TemplateView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.communication.defs#templateView' + ) +} + +export function validateTemplateView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.communication.defs#templateView', v) +} diff --git a/packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts b/packages/api/src/client/types/tools/ozone/communication/deleteTemplate.ts similarity index 100% rename from packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts rename to packages/api/src/client/types/tools/ozone/communication/deleteTemplate.ts diff --git a/packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts b/packages/api/src/client/types/tools/ozone/communication/listTemplates.ts similarity index 84% rename from packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts rename to packages/api/src/client/types/tools/ozone/communication/listTemplates.ts index d37e4688b2b..116f79f1c9c 100644 --- a/packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts +++ b/packages/api/src/client/types/tools/ozone/communication/listTemplates.ts @@ -6,14 +6,14 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneCommunicationDefs from './defs' export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - communicationTemplates: ComAtprotoAdminDefs.CommunicationTemplateView[] + communicationTemplates: ToolsOzoneCommunicationDefs.TemplateView[] [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts b/packages/api/src/client/types/tools/ozone/communication/updateTemplate.ts similarity index 89% rename from packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts rename to packages/api/src/client/types/tools/ozone/communication/updateTemplate.ts index 49c33338c11..2f39d9e9b32 100644 --- a/packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts +++ b/packages/api/src/client/types/tools/ozone/communication/updateTemplate.ts @@ -6,7 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneCommunicationDefs from './defs' export interface QueryParams {} @@ -25,7 +25,7 @@ export interface InputSchema { [k: string]: unknown } -export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView +export type OutputSchema = ToolsOzoneCommunicationDefs.TemplateView export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/tools/ozone/moderation/defs.ts b/packages/api/src/client/types/tools/ozone/moderation/defs.ts new file mode 100644 index 00000000000..f6f546b6bef --- /dev/null +++ b/packages/api/src/client/types/tools/ozone/moderation/defs.ts @@ -0,0 +1,641 @@ +/** + * 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 ComAtprotoAdminDefs from '../../../com/atproto/admin/defs' +import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' +import * as ComAtprotoModerationDefs from '../../../com/atproto/moderation/defs' +import * as ComAtprotoServerDefs from '../../../com/atproto/server/defs' +import * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs' + +export interface ModEventView { + id: number + event: + | ModEventTakedown + | ModEventReverseTakedown + | ModEventComment + | ModEventReport + | ModEventLabel + | ModEventAcknowledge + | ModEventEscalate + | ModEventMute + | ModEventEmail + | ModEventResolveAppeal + | ModEventDivert + | { $type: string; [k: string]: unknown } + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | { $type: string; [k: string]: unknown } + subjectBlobCids: string[] + createdBy: string + createdAt: string + creatorHandle?: string + subjectHandle?: string + [k: string]: unknown +} + +export function isModEventView(v: unknown): v is ModEventView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventView' + ) +} + +export function validateModEventView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventView', v) +} + +export interface ModEventViewDetail { + id: number + event: + | ModEventTakedown + | ModEventReverseTakedown + | ModEventComment + | ModEventReport + | ModEventLabel + | ModEventAcknowledge + | ModEventEscalate + | ModEventMute + | ModEventEmail + | ModEventResolveAppeal + | ModEventDivert + | { $type: string; [k: string]: unknown } + subject: + | RepoView + | RepoViewNotFound + | RecordView + | RecordViewNotFound + | { $type: string; [k: string]: unknown } + subjectBlobs: BlobView[] + createdBy: string + createdAt: string + [k: string]: unknown +} + +export function isModEventViewDetail(v: unknown): v is ModEventViewDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventViewDetail' + ) +} + +export function validateModEventViewDetail(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventViewDetail', v) +} + +export interface SubjectStatusView { + id: number + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | { $type: string; [k: string]: unknown } + subjectBlobCids?: string[] + subjectRepoHandle?: string + /** Timestamp referencing when the last update was made to the moderation status of the subject */ + updatedAt: string + /** Timestamp referencing the first moderation status impacting event was emitted on the subject */ + createdAt: string + reviewState: SubjectReviewState + /** Sticky comment on the subject. */ + comment?: string + muteUntil?: string + lastReviewedBy?: string + lastReviewedAt?: string + lastReportedAt?: string + /** Timestamp referencing when the author of the subject appealed a moderation action */ + lastAppealedAt?: string + takendown?: boolean + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + appealed?: boolean + suspendUntil?: string + tags?: string[] + [k: string]: unknown +} + +export function isSubjectStatusView(v: unknown): v is SubjectStatusView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#subjectStatusView' + ) +} + +export function validateSubjectStatusView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#subjectStatusView', v) +} + +export type SubjectReviewState = + | 'lex:tools.ozone.moderation.defs#reviewOpen' + | 'lex:tools.ozone.moderation.defs#reviewEscalated' + | 'lex:tools.ozone.moderation.defs#reviewClosed' + | 'lex:tools.ozone.moderation.defs#reviewNone' + | (string & {}) + +/** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ +export const REVIEWOPEN = 'tools.ozone.moderation.defs#reviewOpen' +/** Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator */ +export const REVIEWESCALATED = 'tools.ozone.moderation.defs#reviewEscalated' +/** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ +export const REVIEWCLOSED = 'tools.ozone.moderation.defs#reviewClosed' +/** Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it */ +export const REVIEWNONE = 'tools.ozone.moderation.defs#reviewNone' + +/** Take down a subject permanently or temporarily */ +export interface ModEventTakedown { + comment?: string + /** Indicates how long the takedown should be in effect before automatically expiring. */ + durationInHours?: number + [k: string]: unknown +} + +export function isModEventTakedown(v: unknown): v is ModEventTakedown { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventTakedown' + ) +} + +export function validateModEventTakedown(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventTakedown', v) +} + +/** Revert take down action on a subject */ +export interface ModEventReverseTakedown { + /** Describe reasoning behind the reversal. */ + comment?: string + [k: string]: unknown +} + +export function isModEventReverseTakedown( + v: unknown, +): v is ModEventReverseTakedown { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventReverseTakedown' + ) +} + +export function validateModEventReverseTakedown(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventReverseTakedown', + v, + ) +} + +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventResolveAppeal', + v, + ) +} + +/** Add a comment to a subject */ +export interface ModEventComment { + comment: string + /** Make the comment persistent on the subject */ + sticky?: boolean + [k: string]: unknown +} + +export function isModEventComment(v: unknown): v is ModEventComment { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventComment' + ) +} + +export function validateModEventComment(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventComment', v) +} + +/** Report a subject */ +export interface ModEventReport { + comment?: string + reportType: ComAtprotoModerationDefs.ReasonType + [k: string]: unknown +} + +export function isModEventReport(v: unknown): v is ModEventReport { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventReport' + ) +} + +export function validateModEventReport(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventReport', v) +} + +/** Apply/Negate labels on a subject */ +export interface ModEventLabel { + comment?: string + createLabelVals: string[] + negateLabelVals: string[] + [k: string]: unknown +} + +export function isModEventLabel(v: unknown): v is ModEventLabel { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventLabel' + ) +} + +export function validateModEventLabel(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventLabel', v) +} + +export interface ModEventAcknowledge { + comment?: string + [k: string]: unknown +} + +export function isModEventAcknowledge(v: unknown): v is ModEventAcknowledge { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventAcknowledge' + ) +} + +export function validateModEventAcknowledge(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventAcknowledge', v) +} + +export interface ModEventEscalate { + comment?: string + [k: string]: unknown +} + +export function isModEventEscalate(v: unknown): v is ModEventEscalate { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventEscalate' + ) +} + +export function validateModEventEscalate(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventEscalate', v) +} + +/** Mute incoming reports on a subject */ +export interface ModEventMute { + comment?: string + /** Indicates how long the subject should remain muted. */ + durationInHours: number + [k: string]: unknown +} + +export function isModEventMute(v: unknown): v is ModEventMute { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventMute' + ) +} + +export function validateModEventMute(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventMute', v) +} + +/** Unmute action on a subject */ +export interface ModEventUnmute { + /** Describe reasoning behind the reversal. */ + comment?: string + [k: string]: unknown +} + +export function isModEventUnmute(v: unknown): v is ModEventUnmute { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventUnmute' + ) +} + +export function validateModEventUnmute(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventUnmute', v) +} + +/** Keep a log of outgoing email to a user */ +export interface ModEventEmail { + /** The subject line of the email sent to the user. */ + subjectLine: string + /** The content of the email sent to the user. */ + content?: string + /** Additional comment about the outgoing comm. */ + comment?: string + [k: string]: unknown +} + +export function isModEventEmail(v: unknown): v is ModEventEmail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventEmail' + ) +} + +export function validateModEventEmail(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventEmail', v) +} + +/** Divert a record's blobs to a 3rd party service for further scanning/tagging */ +export interface ModEventDivert { + comment?: string + [k: string]: unknown +} + +export function isModEventDivert(v: unknown): v is ModEventDivert { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventDivert' + ) +} + +export function validateModEventDivert(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventDivert', v) +} + +/** Add/Remove a tag on a subject */ +export interface ModEventTag { + /** Tags to be added to the subject. If already exists, won't be duplicated. */ + add: string[] + /** Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated. */ + remove: string[] + /** Additional comment about added/removed tags. */ + comment?: string + [k: string]: unknown +} + +export function isModEventTag(v: unknown): v is ModEventTag { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventTag' + ) +} + +export function validateModEventTag(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#modEventTag', v) +} + +export interface RepoView { + did: string + handle: string + email?: string + relatedRecords: {}[] + indexedAt: string + moderation: Moderation + invitedBy?: ComAtprotoServerDefs.InviteCode + invitesDisabled?: boolean + inviteNote?: string + [k: string]: unknown +} + +export function isRepoView(v: unknown): v is RepoView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#repoView' + ) +} + +export function validateRepoView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#repoView', v) +} + +export interface RepoViewDetail { + did: string + handle: string + email?: string + relatedRecords: {}[] + indexedAt: string + moderation: ModerationDetail + labels?: ComAtprotoLabelDefs.Label[] + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] + invitesDisabled?: boolean + inviteNote?: string + emailConfirmedAt?: string + [k: string]: unknown +} + +export function isRepoViewDetail(v: unknown): v is RepoViewDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#repoViewDetail' + ) +} + +export function validateRepoViewDetail(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#repoViewDetail', v) +} + +export interface RepoViewNotFound { + did: string + [k: string]: unknown +} + +export function isRepoViewNotFound(v: unknown): v is RepoViewNotFound { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#repoViewNotFound' + ) +} + +export function validateRepoViewNotFound(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#repoViewNotFound', v) +} + +export interface RecordView { + uri: string + cid: string + value: {} + blobCids: string[] + indexedAt: string + moderation: Moderation + repo: RepoView + [k: string]: unknown +} + +export function isRecordView(v: unknown): v is RecordView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#recordView' + ) +} + +export function validateRecordView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#recordView', v) +} + +export interface RecordViewDetail { + uri: string + cid: string + value: {} + blobs: BlobView[] + labels?: ComAtprotoLabelDefs.Label[] + indexedAt: string + moderation: ModerationDetail + repo: RepoView + [k: string]: unknown +} + +export function isRecordViewDetail(v: unknown): v is RecordViewDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#recordViewDetail' + ) +} + +export function validateRecordViewDetail(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#recordViewDetail', v) +} + +export interface RecordViewNotFound { + uri: string + [k: string]: unknown +} + +export function isRecordViewNotFound(v: unknown): v is RecordViewNotFound { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#recordViewNotFound' + ) +} + +export function validateRecordViewNotFound(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#recordViewNotFound', v) +} + +export interface Moderation { + subjectStatus?: SubjectStatusView + [k: string]: unknown +} + +export function isModeration(v: unknown): v is Moderation { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#moderation' + ) +} + +export function validateModeration(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#moderation', v) +} + +export interface ModerationDetail { + subjectStatus?: SubjectStatusView + [k: string]: unknown +} + +export function isModerationDetail(v: unknown): v is ModerationDetail { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#moderationDetail' + ) +} + +export function validateModerationDetail(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#moderationDetail', v) +} + +export interface BlobView { + cid: string + mimeType: string + size: number + createdAt: string + details?: + | ImageDetails + | VideoDetails + | { $type: string; [k: string]: unknown } + moderation?: Moderation + [k: string]: unknown +} + +export function isBlobView(v: unknown): v is BlobView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#blobView' + ) +} + +export function validateBlobView(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#blobView', v) +} + +export interface ImageDetails { + width: number + height: number + [k: string]: unknown +} + +export function isImageDetails(v: unknown): v is ImageDetails { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#imageDetails' + ) +} + +export function validateImageDetails(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#imageDetails', v) +} + +export interface VideoDetails { + width: number + height: number + length: number + [k: string]: unknown +} + +export function isVideoDetails(v: unknown): v is VideoDetails { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#videoDetails' + ) +} + +export function validateVideoDetails(v: unknown): ValidationResult { + return lexicons.validate('tools.ozone.moderation.defs#videoDetails', v) +} diff --git a/packages/api/src/client/types/com/atproto/admin/emitModerationEvent.ts b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts similarity index 58% rename from packages/api/src/client/types/com/atproto/admin/emitModerationEvent.ts rename to packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts index 77b460ed1ff..49ad72d208c 100644 --- a/packages/api/src/client/types/com/atproto/admin/emitModerationEvent.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts @@ -6,23 +6,25 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' -import * as ComAtprotoRepoStrongRef from '../repo/strongRef' +import * as ToolsOzoneModerationDefs from './defs' +import * as ComAtprotoAdminDefs from '../../../com/atproto/admin/defs' +import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef' export interface QueryParams {} export interface InputSchema { event: - | ComAtprotoAdminDefs.ModEventTakedown - | ComAtprotoAdminDefs.ModEventAcknowledge - | ComAtprotoAdminDefs.ModEventEscalate - | ComAtprotoAdminDefs.ModEventComment - | ComAtprotoAdminDefs.ModEventLabel - | ComAtprotoAdminDefs.ModEventReport - | ComAtprotoAdminDefs.ModEventMute - | ComAtprotoAdminDefs.ModEventReverseTakedown - | ComAtprotoAdminDefs.ModEventUnmute - | ComAtprotoAdminDefs.ModEventEmail + | ToolsOzoneModerationDefs.ModEventTakedown + | ToolsOzoneModerationDefs.ModEventAcknowledge + | ToolsOzoneModerationDefs.ModEventEscalate + | ToolsOzoneModerationDefs.ModEventComment + | ToolsOzoneModerationDefs.ModEventLabel + | ToolsOzoneModerationDefs.ModEventReport + | ToolsOzoneModerationDefs.ModEventMute + | ToolsOzoneModerationDefs.ModEventReverseTakedown + | ToolsOzoneModerationDefs.ModEventUnmute + | ToolsOzoneModerationDefs.ModEventEmail + | ToolsOzoneModerationDefs.ModEventTag | { $type: string; [k: string]: unknown } subject: | ComAtprotoAdminDefs.RepoRef @@ -33,7 +35,7 @@ export interface InputSchema { [k: string]: unknown } -export type OutputSchema = ComAtprotoAdminDefs.ModEventView +export type OutputSchema = ToolsOzoneModerationDefs.ModEventView export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/com/atproto/admin/getModerationEvent.ts b/packages/api/src/client/types/tools/ozone/moderation/getEvent.ts similarity index 83% rename from packages/api/src/client/types/com/atproto/admin/getModerationEvent.ts rename to packages/api/src/client/types/tools/ozone/moderation/getEvent.ts index 8a107172929..fef7068170e 100644 --- a/packages/api/src/client/types/com/atproto/admin/getModerationEvent.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/getEvent.ts @@ -6,14 +6,14 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneModerationDefs from './defs' export interface QueryParams { id: number } export type InputSchema = undefined -export type OutputSchema = ComAtprotoAdminDefs.ModEventViewDetail +export type OutputSchema = ToolsOzoneModerationDefs.ModEventViewDetail export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/com/atproto/admin/getRecord.ts b/packages/api/src/client/types/tools/ozone/moderation/getRecord.ts similarity index 87% rename from packages/api/src/client/types/com/atproto/admin/getRecord.ts rename to packages/api/src/client/types/tools/ozone/moderation/getRecord.ts index 453e94c39d7..9a1168033c5 100644 --- a/packages/api/src/client/types/com/atproto/admin/getRecord.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/getRecord.ts @@ -6,7 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneModerationDefs from './defs' export interface QueryParams { uri: string @@ -14,7 +14,7 @@ export interface QueryParams { } export type InputSchema = undefined -export type OutputSchema = ComAtprotoAdminDefs.RecordViewDetail +export type OutputSchema = ToolsOzoneModerationDefs.RecordViewDetail export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/com/atproto/admin/getRepo.ts b/packages/api/src/client/types/tools/ozone/moderation/getRepo.ts similarity index 87% rename from packages/api/src/client/types/com/atproto/admin/getRepo.ts rename to packages/api/src/client/types/tools/ozone/moderation/getRepo.ts index 5391bada281..6df65f90aa7 100644 --- a/packages/api/src/client/types/com/atproto/admin/getRepo.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/getRepo.ts @@ -6,14 +6,14 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneModerationDefs from './defs' export interface QueryParams { did: string } export type InputSchema = undefined -export type OutputSchema = ComAtprotoAdminDefs.RepoViewDetail +export type OutputSchema = ToolsOzoneModerationDefs.RepoViewDetail export interface CallOptions { headers?: Headers diff --git a/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts b/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts new file mode 100644 index 00000000000..268c570e607 --- /dev/null +++ b/packages/api/src/client/types/tools/ozone/moderation/queryEvents.ts @@ -0,0 +1,63 @@ +/** + * 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 ToolsOzoneModerationDefs from './defs' + +export interface QueryParams { + /** The types of events (fully qualified string in the format of tools.ozone.moderation.defs#modEvent) to filter by. If not specified, all events are returned. */ + types?: string[] + createdBy?: string + /** Sort direction for the events. Defaults to descending order of created at timestamp. */ + sortDirection?: 'asc' | 'desc' + /** Retrieve events created after a given timestamp */ + createdAfter?: string + /** Retrieve events created before a given timestamp */ + createdBefore?: string + subject?: string + /** If true, events on all record types (posts, lists, profile etc.) owned by the did are returned */ + includeAllUserRecords?: boolean + limit?: number + /** If true, only events with comments are returned */ + hasComment?: boolean + /** If specified, only events with comments containing the keyword are returned */ + comment?: string + /** If specified, only events where all of these labels were added are returned */ + addedLabels?: string[] + /** If specified, only events where all of these labels were removed are returned */ + removedLabels?: string[] + /** If specified, only events where all of these tags were added are returned */ + addedTags?: string[] + /** If specified, only events where all of these tags were removed are returned */ + removedTags?: string[] + reportTypes?: string[] + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + events: ToolsOzoneModerationDefs.ModEventView[] + [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/com/atproto/admin/queryModerationStatuses.ts b/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts similarity index 91% rename from packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts rename to packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts index 0039016a353..55701ca94d4 100644 --- a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts @@ -6,7 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneModerationDefs from './defs' export interface QueryParams { subject?: string @@ -34,6 +34,8 @@ export interface QueryParams { /** Get subjects in unresolved appealed status */ appealed?: boolean limit?: number + tags?: string[] + excludeTags?: string[] cursor?: string } @@ -41,7 +43,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string - subjectStatuses: ComAtprotoAdminDefs.SubjectStatusView[] + subjectStatuses: ToolsOzoneModerationDefs.SubjectStatusView[] [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts b/packages/api/src/client/types/tools/ozone/moderation/searchRepos.ts similarity index 88% rename from packages/api/src/client/types/com/atproto/admin/searchRepos.ts rename to packages/api/src/client/types/tools/ozone/moderation/searchRepos.ts index 451077479b9..3d56a3876b0 100644 --- a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/searchRepos.ts @@ -6,7 +6,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -import * as ComAtprotoAdminDefs from './defs' +import * as ToolsOzoneModerationDefs from './defs' export interface QueryParams { /** DEPRECATED: use 'q' instead */ @@ -20,7 +20,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string - repos: ComAtprotoAdminDefs.RepoView[] + repos: ToolsOzoneModerationDefs.RepoView[] [k: string]: unknown } diff --git a/packages/api/src/const.ts b/packages/api/src/const.ts new file mode 100644 index 00000000000..7575c55d3a9 --- /dev/null +++ b/packages/api/src/const.ts @@ -0,0 +1 @@ +export const BSKY_LABELER_DID = 'did:plc:ar7c4by46qjdydhdevvrndac' diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 958e8930603..64fbb557f43 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,14 +8,17 @@ 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' export * from './rich-text/rich-text' export * from './rich-text/sanitization' 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..556dba965c8 --- /dev/null +++ b/packages/api/src/mocker.ts @@ -0,0 +1,218 @@ +import { + ComAtprotoLabelDefs, + AppBskyFeedDefs, + AppBskyActorDefs, + AppBskyFeedPost, + AppBskyEmbedRecord, + AppBskyGraphDefs, + AppBskyNotificationListNotifications, +} from './client' + +const FAKE_CID = 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq' + +export const mock = { + post({ + text, + facets, + reply, + embed, + }: { + text: string + facets?: AppBskyFeedPost.Record['facets'] + reply?: AppBskyFeedPost.ReplyRef + embed?: AppBskyFeedPost.Record['embed'] + }): AppBskyFeedPost.Record { + return { + $type: 'app.bsky.feed.post', + text, + facets, + reply, + embed, + langs: ['en'], + 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 { + $type: 'app.bsky.feed.defs#postView', + 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..53be497191f 100644 --- a/packages/api/src/moderation/const/labels.ts +++ b/packages/api/src/moderation/const/labels.ts @@ -1,859 +1,196 @@ /** this doc is generated by ./scripts/code/labels.mjs **/ -import { LabelDefinitionMap } from '../types' +import { InterpretedLabelValueDefinition, 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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - 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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - 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.', - }, - }, - }, - }, - 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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, - '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.', - }, - }, - }, - }, +export type KnownLabelValue = + | '!hide' + | '!warn' + | '!no-unauthenticated' + | 'porn' + | 'sexual' + | 'nudity' + | 'graphic-media' + +export const DEFAULT_LABEL_SETTINGS: Record = { + porn: 'hide', + sexual: 'warn', + nudity: 'ignore', + 'graphic-media': '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: [], + }, + '!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: [], + }, + '!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: [], + }, + 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: [], + }, + 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: [], + }, + nudity: { + identifier: 'nudity', + configurable: true, + defaultSetting: 'ignore', + flags: [], + severity: 'none', + blurs: 'media', + behaviors: { + account: { + avatar: 'blur', + banner: 'blur', + }, + profile: { + avatar: 'blur', + banner: 'blur', + }, + content: { + contentMedia: 'blur', + }, + }, + locales: [], + }, + 'graphic-media': { + identifier: 'graphic-media', + 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: [], + }, + } diff --git a/packages/api/src/moderation/decision.ts b/packages/api/src/moderation/decision.ts new file mode 100644 index 00000000000..b8a76206e67 --- /dev/null +++ b/packages/api/src/moderation/decision.ts @@ -0,0 +1,383 @@ +import { AppBskyGraphDefs } from '../client/index' +import { + BLOCK_BEHAVIOR, + MUTE_BEHAVIOR, + MUTEWORD_BEHAVIOR, + HIDE_BEHAVIOR, + NOOP_BEHAVIOR, + Label, + LabelPreference, + ModerationCause, + ModerationOpts, + 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 decisionsFiltered: ModerationDecision[] = decisions.filter( + (v) => !!v, + ) as ModerationDecision[] + const decision = new ModerationDecision() + if (decisionsFiltered[0]) { + decision.did = decisionsFiltered[0].did + decision.isMe = decisionsFiltered[0].isMe + } + decision.causes = decisionsFiltered.flatMap((d) => d.causes) + return decision + } + + downgrade() { + for (const cause of this.causes) { + cause.downgraded = true + } + return this + } + + get blocked() { + return !!this.blockCause + } + + 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 (!cause.downgraded) { + if (BLOCK_BEHAVIOR[context] === 'blur') { + ui.noOverride = true + ui.blurs.push(cause) + } else if (BLOCK_BEHAVIOR[context] === 'alert') { + ui.alerts.push(cause) + } else if (BLOCK_BEHAVIOR[context] === 'inform') { + ui.informs.push(cause) + } + } + } else if (cause.type === 'muted') { + if (context === 'profileList' || context === 'contentList') { + ui.filters.push(cause) + } + if (!cause.downgraded) { + if (MUTE_BEHAVIOR[context] === 'blur') { + ui.blurs.push(cause) + } else if (MUTE_BEHAVIOR[context] === 'alert') { + ui.alerts.push(cause) + } else if (MUTE_BEHAVIOR[context] === 'inform') { + ui.informs.push(cause) + } + } + } else if (cause.type === 'mute-word') { + if (context === 'contentList') { + ui.filters.push(cause) + } + if (!cause.downgraded) { + if (MUTEWORD_BEHAVIOR[context] === 'blur') { + ui.blurs.push(cause) + } else if (MUTEWORD_BEHAVIOR[context] === 'alert') { + ui.alerts.push(cause) + } else if (MUTEWORD_BEHAVIOR[context] === 'inform') { + ui.informs.push(cause) + } + } + } else if (cause.type === 'hidden') { + if (context === 'profileList' || context === 'contentList') { + ui.filters.push(cause) + } + if (!cause.downgraded) { + if (HIDE_BEHAVIOR[context] === 'blur') { + ui.blurs.push(cause) + } else if (HIDE_BEHAVIOR[context] === 'alert') { + ui.alerts.push(cause) + } else if (HIDE_BEHAVIOR[context] === 'inform') { + ui.informs.push(cause) + } + } + } else if (cause.type === 'label') { + if (context === 'profileList' && cause.target === 'account') { + if (cause.setting === 'hide') { + ui.filters.push(cause) + } + } else if ( + context === 'contentList' && + (cause.target === 'account' || cause.target === 'content') + ) { + if (cause.setting === 'hide') { + ui.filters.push(cause) + } + } + if (!cause.downgraded) { + if (cause.behavior[context] === 'blur') { + ui.blurs.push(cause) + if (cause.noOverride) { + ui.noOverride = true + } + } else if (cause.behavior[context] === 'alert') { + ui.alerts.push(cause) + } else if (cause.behavior[context] === 'inform') { + ui.informs.push(cause) + } + } + } + } + + 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, + }) + } + } + + addMutedWord(mutedWord: boolean) { + if (mutedWord) { + this.causes.push({ + type: 'mute-word', + source: { type: 'user' }, + priority: 6, + }) + } + } + + addBlocking(blocking: string | undefined) { + if (blocking) { + this.causes.push({ + 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.labelers.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 = labelDef.defaultSetting || '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, + target, + 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..503e635c816 100644 --- a/packages/api/src/moderation/index.ts +++ b/packages/api/src/moderation/index.ts @@ -1,346 +1,61 @@ -import { AppBskyActorDefs } from '../client/index' import { ModerationSubjectProfile, ModerationSubjectPost, + ModerationSubjectNotification, ModerationSubjectFeedGenerator, ModerationSubjectUserList, ModerationOpts, - ModerationDecision, - ModerationUI, } from './types' import { decideAccount } from './subjects/account' import { decideProfile } from './subjects/profile' +import { decideNotification } from './subjects/notification' import { decidePost } from './subjects/post' -import { - decideQuotedPost, - decideQuotedPostAccount, - decideQuotedPostWithMedia, - decideQuotedPostWithMediaAccount, -} from './subjects/quoted-post' import { decideFeedGenerator } from './subjects/feed-generator' import { decideUserList } from './subjects/user-list' -import { - takeHighestPriorityDecision, - downgradeDecision, - isModerationDecisionNoop, - isQuotedPost, - isQuotedPostWithMedia, - toModerationUI, +import { ModerationDecision } from './decision' + +export { ModerationUI } from './ui' +export { ModerationDecision } from './decision' +export { hasMutedWord } from './mutewords' +export { + interpretLabelValueDefinition, + interpretLabelValueDefinitions, } from './util' -// profiles -// = - -export interface ProfileModeration { - decisions: { - account: ModerationDecision - profile: ModerationDecision - } - account: ModerationUI - profile: ModerationUI - avatar: ModerationUI -} - export function moderateProfile( subject: ModerationSubjectProfile, opts: ModerationOpts, -): ProfileModeration { - // decide the moderation the account and the profile - const account = decideAccount(subject, opts) - const profile = decideProfile(subject, opts) - - // if the decision is supposed to blur media, - // - have it apply to the view if it's on the account - // - otherwise ignore it - if (account.blurMedia) { - account.blur = true - } - - // don't give profile.filter because that is meaningless - profile.filter = false - - // downgrade based on authorship - if (!isModerationDecisionNoop(account) && account.did === opts.userDid) { - downgradeDecision(account, 'alert') - } - if (!isModerationDecisionNoop(profile) && profile.did === opts.userDid) { - downgradeDecision(profile, 'alert') - } - - // derive avatar blurring from account & profile, but override for mutes because that shouldn't blur - let avatarBlur = false - let avatarNoOverride = false - if ((account.blur || account.blurMedia) && account.cause?.type !== 'muted') { - avatarBlur = true - avatarNoOverride = account.noOverride || profile.noOverride - } else if (profile.blur || profile.blurMedia) { - avatarBlur = true - avatarNoOverride = account.noOverride || profile.noOverride - } - - // don't blur the account for blocking & muting - if ( - account.cause?.type === 'blocking' || - account.cause?.type === 'blocked-by' || - account.cause?.type === 'muted' - ) { - account.blur = false - account.noOverride = false - } - - return { - decisions: { account, profile }, - - // moderate all content based on account - account: - account.filter || account.blur || account.alert - ? toModerationUI(account) - : {}, - - // moderate the profile details based on the profile - profile: - profile.filter || profile.blur || profile.alert - ? toModerationUI(profile) - : {}, - - // blur or alert the avatar based on the account and profile decisions - avatar: { - blur: avatarBlur, - alert: account.alert || profile.alert, - noOverride: avatarNoOverride, - }, - } -} - -// posts -// = - -export interface PostModeration { - decisions: { - post: ModerationDecision - account: ModerationDecision - profile: ModerationDecision - quote?: ModerationDecision - quotedAccount?: ModerationDecision - } - content: ModerationUI - avatar: ModerationUI - embed: ModerationUI +): ModerationDecision { + return ModerationDecision.merge( + decideAccount(subject, opts), + decideProfile(subject, opts), + ) } export function moderatePost( subject: ModerationSubjectPost, opts: ModerationOpts, -): PostModeration { - // decide the moderation for the post, the post author's account, - // and the post author's profile - const post = decidePost(subject, opts) - const account = decideAccount(subject.author, opts) - const profile = decideProfile(subject.author, opts) - - // decide the moderation for any quoted posts - let quote: ModerationDecision | undefined - let quotedAccount: ModerationDecision | undefined - if (isQuotedPost(subject.embed)) { - quote = decideQuotedPost(subject.embed, opts) - quotedAccount = decideQuotedPostAccount(subject.embed, opts) - } else if (isQuotedPostWithMedia(subject.embed)) { - quote = decideQuotedPostWithMedia(subject.embed, opts) - quotedAccount = decideQuotedPostWithMediaAccount(subject.embed, opts) - } - if (quote?.blurMedia) { - quote.blur = true // treat blurMedia of quote as blur of quote - } - - // downgrade based on authorship - if (!isModerationDecisionNoop(post) && post.did === opts.userDid) { - downgradeDecision(post, 'blur') - } - if (account.cause && account.did === opts.userDid) { - downgradeDecision(account, 'noop') - } - if (profile.cause && profile.did === opts.userDid) { - downgradeDecision(profile, 'noop') - } - if (quote && !isModerationDecisionNoop(quote) && quote.did === opts.userDid) { - downgradeDecision(quote, 'blur') - } - if ( - quotedAccount && - !isModerationDecisionNoop(quotedAccount) && - quotedAccount.did === opts.userDid - ) { - downgradeDecision(quotedAccount, 'noop') - } - - // derive filtering from feeds from the post, post author's account, - // quoted post, and quoted post author's account - const mergedForFeed = takeHighestPriorityDecision( - post, - account, - quote, - quotedAccount, - ) - - // 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, - } - : {}, - } +): ModerationDecision { + return decidePost(subject, opts) } -// 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 decideNotification(subject, 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 decideFeedGenerator(subject, 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 - const userList = decideUserList(subject, opts) - const account = AppBskyActorDefs.isProfileViewBasic(subject.creator) - ? decideAccount(subject.creator, opts) - : ModerationDecision.noop() - 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, - }, - } +): ModerationDecision { + return decideUserList(subject, opts) } diff --git a/packages/api/src/moderation/mutewords.ts b/packages/api/src/moderation/mutewords.ts new file mode 100644 index 00000000000..8988f3dc2b6 --- /dev/null +++ b/packages/api/src/moderation/mutewords.ts @@ -0,0 +1,101 @@ +import { AppBskyActorDefs, AppBskyRichtextFacet } from '../client' + +const REGEX = { + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, + SEPARATORS: /[/\-–—()[\]_]+/g, + WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, +} + +/** + * List of 2-letter lang codes for languages that either don't use spaces, or + * don't use spaces in a way conducive to word-based filtering. + * + * For these, we use a simple `String.includes` to check for a match. + */ +const LANGUAGE_EXCEPTIONS = [ + 'ja', // Japanese + 'zh', // Chinese + 'ko', // Korean + 'th', // Thai + 'vi', // Vietnamese +] + +export function hasMutedWord({ + mutedWords, + text, + facets, + outlineTags, + languages, +}: { + mutedWords: AppBskyActorDefs.MutedWord[] + text: string + facets?: AppBskyRichtextFacet.Main[] + outlineTags?: string[] + languages?: string[] +}) { + const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') + const tags = ([] as string[]) + .concat(outlineTags || []) + .concat( + facets + ?.filter((facet) => { + return facet.features.find((feature) => + AppBskyRichtextFacet.isTag(feature), + ) + }) + .map((t) => t.features[0].tag as string) || [], + ) + .map((t) => t.toLowerCase()) + + for (const mute of mutedWords) { + const mutedWord = mute.value.toLowerCase() + const postText = text.toLowerCase() + + // `content` applies to tags as well + if (tags.includes(mutedWord)) return true + // rest of the checks are for `content` only + if (!mute.targets.includes('content')) continue + // single character or other exception, has to use includes + if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord)) + return true + // too long + if (mutedWord.length > postText.length) continue + // exact match + if (mutedWord === postText) return true + // any muted phrase with space or punctuation + if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) + return true + + // check individual character groups + const words = postText.split(REGEX.WORD_BOUNDARY) + for (const word of words) { + if (word === mutedWord) return true + + // compare word without leading/trailing punctuation, but allow internal + // punctuation (such as `s@ssy`) + const wordTrimmedPunctuation = word.replace( + REGEX.LEADING_TRAILING_PUNCTUATION, + '', + ) + + if (mutedWord === wordTrimmedPunctuation) return true + if (mutedWord.length > wordTrimmedPunctuation.length) continue + + if (/\p{P}+/u.test(wordTrimmedPunctuation)) { + const spacedWord = wordTrimmedPunctuation.replace(/\p{P}+/gu, ' ') + if (spacedWord === mutedWord) return true + + const contiguousWord = spacedWord.replace(/\s/gu, '') + if (contiguousWord === mutedWord) return true + + const wordParts = wordTrimmedPunctuation.split(/\p{P}+/u) + for (const wordPart of wordParts) { + if (wordPart === mutedWord) return true + } + } + } + } + + return false +} 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..3afada34b2d 100644 --- a/packages/api/src/moderation/subjects/feed-generator.ts +++ b/packages/api/src/moderation/subjects/feed-generator.ts @@ -1,13 +1,24 @@ -import { - ModerationSubjectFeedGenerator, - ModerationDecision, - ModerationOpts, -} from '../types' +import { ModerationDecision } from '../decision' +import { ModerationSubjectFeedGenerator, ModerationOpts } from '../types' +import { decideAccount } from './account' +import { decideProfile } from './profile' export function decideFeedGenerator( - _subject: ModerationSubjectFeedGenerator, - _opts: ModerationOpts, + subject: ModerationSubjectFeedGenerator, + opts: ModerationOpts, ): ModerationDecision { - // TODO handle labels applied on the feed generator itself - return ModerationDecision.noop() + const acc = new ModerationDecision() + + acc.setDid(subject.creator.did) + acc.setIsMe(subject.creator.did === opts.userDid) + if (subject.labels?.length) { + for (const label of subject.labels) { + acc.addLabel('content', label, opts) + } + } + return ModerationDecision.merge( + acc, + decideAccount(subject.creator, opts), + decideProfile(subject.creator, opts), + ) } diff --git a/packages/api/src/moderation/subjects/notification.ts b/packages/api/src/moderation/subjects/notification.ts new file mode 100644 index 00000000000..610766866a8 --- /dev/null +++ b/packages/api/src/moderation/subjects/notification.ts @@ -0,0 +1,25 @@ +import { ModerationDecision } from '../decision' +import { ModerationSubjectNotification, ModerationOpts } from '../types' +import { decideAccount } from './account' +import { decideProfile } from './profile' + +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 ModerationDecision.merge( + acc, + decideAccount(subject.author, opts), + decideProfile(subject.author, opts), + ) +} diff --git a/packages/api/src/moderation/subjects/post.ts b/packages/api/src/moderation/subjects/post.ts index 577b5374df1..1274a453e29 100644 --- a/packages/api/src/moderation/subjects/post.ts +++ b/packages/api/src/moderation/subjects/post.ts @@ -1,23 +1,312 @@ -import { ModerationCauseAccumulator } from '../accumulator' +import { ModerationDecision } from '../decision' import { - ModerationSubjectPost, - ModerationOpts, - ModerationDecision, -} from '../types' + AppBskyFeedPost, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedExternal, + AppBskyActorDefs, +} from '../../client' +import { ModerationSubjectPost, ModerationOpts } from '../types' +import { hasMutedWord } from '../mutewords' +import { decideAccount } from './account' +import { decideProfile } from './profile' export function decidePost( subject: ModerationSubjectPost, 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('content', label, opts) + } + } + acc.addHidden(checkHiddenPost(subject, opts.prefs.hiddenPosts)) + if (!acc.isMe) { + acc.addMutedWord(checkMutedWords(subject, opts.prefs.mutedWords)) + } + + let embedAcc + if (subject.embed) { + if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { + // quote post + embedAcc = decideQuotedPost(subject.embed.record, opts) + } else if ( + AppBskyEmbedRecordWithMedia.isView(subject.embed) && + AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) + ) { + // quoted post with media + embedAcc = decideQuotedPost(subject.embed.record.record, opts) + } else if (AppBskyEmbedRecord.isViewBlocked(subject.embed.record)) { + // blocked quote post + embedAcc = decideBlockedQuotedPost(subject.embed.record, opts) + } else if ( + AppBskyEmbedRecordWithMedia.isView(subject.embed) && + AppBskyEmbedRecord.isViewBlocked(subject.embed.record.record) + ) { + // blocked quoted post with media + embedAcc = decideBlockedQuotedPost(subject.embed.record.record, opts) + } + } + + return ModerationDecision.merge( + acc, + embedAcc?.downgrade(), + decideAccount(subject.author, opts), + decideProfile(subject.author, opts), + ) +} +function decideQuotedPost( + subject: AppBskyEmbedRecord.ViewRecord, + opts: ModerationOpts, +) { + const acc = new ModerationDecision() + acc.setDid(subject.author.did) + acc.setIsMe(subject.author.did === opts.userDid) if (subject.labels?.length) { for (const label of subject.labels) { - acc.addLabel(label, opts) + acc.addLabel('content', label, opts) } } + return ModerationDecision.merge( + acc, + decideAccount(subject.author, opts), + decideProfile(subject.author, opts), + ) +} + +function decideBlockedQuotedPost( + subject: AppBskyEmbedRecord.ViewBlocked, + opts: ModerationOpts, +) { + const acc = new ModerationDecision() + acc.setDid(subject.author.did) + acc.setIsMe(subject.author.did === opts.userDid) + if (subject.author.viewer?.muted) { + if (subject.author.viewer?.mutedByList) { + acc.addMutedByList(subject.author.viewer?.mutedByList) + } else { + acc.addMuted(subject.author.viewer?.muted) + } + } + if (subject.author.viewer?.blocking) { + if (subject.author.viewer?.blockingByList) { + acc.addBlockingByList(subject.author.viewer?.blockingByList) + } else { + acc.addBlocking(subject.author.viewer?.blocking) + } + } + acc.addBlockedBy(subject.author.viewer?.blockedBy) + return acc +} - return acc.finalizeDecision(opts) +function checkHiddenPost( + subject: ModerationSubjectPost, + hiddenPosts: string[] | undefined, +) { + if (!hiddenPosts?.length) { + return false + } + if (hiddenPosts.includes(subject.uri)) { + return true + } + if (subject.embed) { + if ( + AppBskyEmbedRecord.isViewRecord(subject.embed.record) && + hiddenPosts.includes(subject.embed.record.uri) + ) { + return true + } + if ( + AppBskyEmbedRecordWithMedia.isView(subject.embed) && + AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) && + hiddenPosts.includes(subject.embed.record.record.uri) + ) { + return true + } + } + return false +} + +function checkMutedWords( + subject: ModerationSubjectPost, + mutedWords: AppBskyActorDefs.MutedWord[] | undefined, +) { + if (!mutedWords?.length) { + return false + } + + if (AppBskyFeedPost.isRecord(subject.record)) { + // post text + if ( + hasMutedWord({ + mutedWords, + text: subject.record.text, + facets: subject.record.facets, + outlineTags: subject.record.tags, + languages: subject.record.langs, + }) + ) { + return true + } + + if ( + subject.record.embed && + AppBskyEmbedImages.isMain(subject.record.embed) + ) { + // post images + for (const image of subject.record.embed.images) { + if ( + hasMutedWord({ + mutedWords, + text: image.alt, + languages: subject.record.langs, + }) + ) { + return true + } + } + } + } + + if (subject.embed) { + // quote post + if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { + if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { + const embeddedPost = subject.embed.record.value + + // quoted post text + if ( + hasMutedWord({ + mutedWords, + text: embeddedPost.text, + facets: embeddedPost.facets, + outlineTags: embeddedPost.tags, + languages: embeddedPost.langs, + }) + ) { + return true + } + + // quoted post's images + if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { + for (const image of embeddedPost.embed.images) { + if ( + hasMutedWord({ + mutedWords, + text: image.alt, + languages: embeddedPost.langs, + }) + ) { + return true + } + } + } + + // quoted post's link card + if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) { + const { external } = embeddedPost.embed + if ( + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + languages: [], + }) + ) { + return true + } + } + + if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) { + // quoted post's link card when it did a quote + media + if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) { + const { external } = embeddedPost.embed.media + if ( + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + languages: [], + }) + ) { + return true + } + } + + // quoted post's images when it did a quote + media + if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) { + for (const image of embeddedPost.embed.media.images) { + if ( + hasMutedWord({ + mutedWords, + text: image.alt, + languages: AppBskyFeedPost.isRecord(embeddedPost.record) + ? embeddedPost.langs + : [], + }) + ) { + return true + } + } + } + } + } + } + // link card + else if (AppBskyEmbedExternal.isView(subject.embed)) { + const { external } = subject.embed + if ( + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + languages: [], + }) + ) { + return true + } + } + // quote post with media + else if ( + AppBskyEmbedRecordWithMedia.isView(subject.embed) && + AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) + ) { + // quoted post text + if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) { + const post = subject.embed.record.record.value + if ( + hasMutedWord({ + mutedWords, + text: post.text, + facets: post.facets, + outlineTags: post.tags, + languages: post.langs, + }) + ) { + return true + } + } + + // quoted post images + if (AppBskyEmbedImages.isView(subject.embed.media)) { + for (const image of subject.embed.media.images) { + if ( + hasMutedWord({ + mutedWords, + text: image.alt, + languages: AppBskyFeedPost.isRecord(subject.record) + ? subject.record.langs + : [], + }) + ) { + return true + } + } + } + } + } + return false } diff --git a/packages/api/src/moderation/subjects/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..f5ed15177d9 100644 --- a/packages/api/src/moderation/subjects/user-list.ts +++ b/packages/api/src/moderation/subjects/user-list.ts @@ -1,13 +1,44 @@ -import { - ModerationSubjectUserList, - ModerationOpts, - ModerationDecision, -} from '../types' +import { AtUri } from '@atproto/syntax' +import { AppBskyActorDefs } from '../../client/index' +import { ModerationDecision } from '../decision' +import { ModerationSubjectUserList, ModerationOpts } from '../types' +import { decideAccount } from './account' +import { decideProfile } from './profile' export function decideUserList( - _subject: ModerationSubjectUserList, - _opts: ModerationOpts, + subject: ModerationSubjectUserList, + opts: ModerationOpts, ): ModerationDecision { - // TODO handle labels applied on the list itself - return ModerationDecision.noop() + const acc = new ModerationDecision() + + const creator = isProfile(subject.creator) ? subject.creator : undefined + + if (creator) { + acc.setDid(creator.did) + acc.setIsMe(creator.did === opts.userDid) + if (subject.labels?.length) { + for (const label of subject.labels) { + acc.addLabel('content', label, opts) + } + } + return ModerationDecision.merge( + acc, + decideAccount(creator, opts), + decideProfile(creator, opts), + ) + } + + const creatorDid = new AtUri(subject.uri).hostname + acc.setDid(creatorDid) + acc.setIsMe(creatorDid === opts.userDid) + if (subject.labels?.length) { + for (const label of subject.labels) { + acc.addLabel('content', label, opts) + } + } + return acc +} + +function isProfile(v: any): v is AppBskyActorDefs.ProfileViewBasic { + return v && typeof v === 'object' && 'did' in v } diff --git a/packages/api/src/moderation/types.ts b/packages/api/src/moderation/types.ts index b60b3ee593a..bbf8d842f23 100644 --- a/packages/api/src/moderation/types.ts +++ b/packages/api/src/moderation/types.ts @@ -1,72 +1,85 @@ import { AppBskyActorDefs, AppBskyFeedDefs, + AppBskyNotificationListNotifications, AppBskyGraphDefs, ComAtprotoLabelDefs, } from '../client/index' +import { KnownLabelValue } from './const/labels' -// labels +// syntax // = -export type Label = ComAtprotoLabelDefs.Label +export const CUSTOM_LABEL_VALUE_RE = /^[a-z-]+$/ -export type LabelPreference = 'ignore' | 'warn' | 'hide' -export type LabelDefinitionFlag = 'no-override' | 'adult' | 'unauthed' -export type LabelDefinitionOnWarnBehavior = - | 'blur' - | 'blur-media' - | 'alert' - | null - -export interface LabelDefinitionLocalizedStrings { - name: string - description: string -} - -export type 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 MUTEWORD_BEHAVIOR: ModerationBehavior = { + contentList: 'blur', + contentView: 'blur', +} +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 InterpretedLabelValueDefinition + extends ComAtprotoLabelDefs.LabelValueDefinition { + definedBy?: string | undefined // did of labeler or undefined for global + configurable: boolean + defaultSetting: LabelPreference // type narrowing + flags: LabelValueDefinitionFlag[] + behaviors: { + account?: ModerationBehavior + profile?: ModerationBehavior + content?: ModerationBehavior + } } +export type LabelDefinitionMap = Record< + KnownLabelValue, + InterpretedLabelValueDefinition +> + // subjects // = @@ -77,6 +90,9 @@ export type ModerationSubjectProfile = export type ModerationSubjectPost = AppBskyFeedDefs.PostView +export type ModerationSubjectNotification = + AppBskyNotificationListNotifications.Notification + export type ModerationSubjectFeedGenerator = AppBskyFeedDefs.GeneratorView export type ModerationSubjectUserList = @@ -86,6 +102,7 @@ export type ModerationSubjectUserList = export type ModerationSubject = | ModerationSubjectProfile | ModerationSubjectPost + | ModerationSubjectNotification | ModerationSubjectFeedGenerator | ModerationSubjectUserList @@ -95,50 +112,76 @@ 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 } - | { type: 'blocked-by'; source: ModerationCauseSource; priority: 4 } - | { type: 'block-other'; source: ModerationCauseSource; priority: 4 } + | { + type: 'blocking' + source: ModerationCauseSource + priority: 3 + downgraded?: boolean + } + | { + type: 'blocked-by' + source: ModerationCauseSource + priority: 4 + downgraded?: boolean + } + | { + type: 'block-other' + source: ModerationCauseSource + priority: 4 + downgraded?: boolean + } | { type: 'label' source: ModerationCauseSource label: Label - labelDef: LabelDefinition + labelDef: InterpretedLabelValueDefinition + target: LabelTarget setting: LabelPreference + behavior: ModerationBehavior + noOverride: boolean priority: 1 | 2 | 5 | 7 | 8 + downgraded?: boolean + } + | { + type: 'muted' + source: ModerationCauseSource + priority: 6 + downgraded?: boolean + } + | { + type: 'mute-word' + source: ModerationCauseSource + priority: 6 + downgraded?: boolean + } + | { + type: 'hidden' + source: ModerationCauseSource + priority: 6 + downgraded?: boolean } - | { type: 'muted'; source: ModerationCauseSource; priority: 6 } -export interface ModerationOpts { - userDid: string - adultContentEnabled: boolean +export interface ModerationPrefsLabeler { + 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 + labelers: ModerationPrefsLabeler[] + mutedWords: AppBskyActorDefs.MutedWord[] + hiddenPosts: string[] } -export interface ModerationUI { - filter?: boolean - blur?: boolean - alert?: boolean - cause?: ModerationCause - noOverride?: boolean +export interface ModerationOpts { + userDid: string | undefined + prefs: ModerationPrefs + /** + * Map of labeler did -> custom definitions + */ + labelDefs?: Record } 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..aaf800aa8aa 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -1,69 +1,15 @@ -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 { + InterpretedLabelValueDefinition, + ModerationBehavior, + LabelPreference, + LabelValueDefinitionFlag, +} from './types' export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View { return Boolean(embed && AppBskyEmbedRecord.isView(embed)) @@ -75,12 +21,93 @@ export function isQuotedPostWithMedia( return Boolean(embed && AppBskyEmbedRecordWithMedia.isView(embed)) } -export function toModerationUI(decision: ModerationDecision): ModerationUI { +export function interpretLabelValueDefinition( + def: ComAtprotoLabelDefs.LabelValueDefinition, + definedBy: string | undefined, +): InterpretedLabelValueDefinition { + 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 = def.adultOnly ? 'blur' : alertOrInform + // target=profile, blurs=content + behaviors.profile.profileList = alertOrInform + behaviors.profile.profileView = alertOrInform + // target=content, blurs=content + behaviors.content.contentList = 'blur' + behaviors.content.contentView = def.adultOnly ? 'blur' : alertOrInform + } else if (def.blurs === 'media') { + // target=account, blurs=media + behaviors.account.profileList = alertOrInform + behaviors.account.profileView = alertOrInform + behaviors.account.avatar = 'blur' + behaviors.account.banner = 'blur' + // target=profile, blurs=media + behaviors.profile.profileList = alertOrInform + 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.profileList = alertOrInform + behaviors.profile.profileView = alertOrInform + // target=content, blurs=none + behaviors.content.contentList = alertOrInform + behaviors.content.contentView = alertOrInform + } + + let defaultSetting: LabelPreference = 'warn' + if (def.defaultSetting === 'hide' || def.defaultSetting === 'ignore') { + defaultSetting = def.defaultSetting as LabelPreference + } + + const flags: LabelValueDefinitionFlag[] = ['no-self'] + if (def.adultOnly) { + flags.push('adult') + } + return { - cause: decision.cause, - filter: decision.filter, - blur: decision.blur, - alert: decision.alert, - noOverride: decision.noOverride, + ...def, + definedBy, + configurable: true, + defaultSetting, + flags, + behaviors, } } + +export function interpretLabelValueDefinitions( + labelerView: AppBskyLabelerDefs.LabelerViewDetailed, +): InterpretedLabelValueDefinition[] { + return (labelerView.policies?.labelValueDefinitions || []) + .filter( + (labelValDef) => + ComAtprotoLabelDefs.validateLabelValueDefinition(labelValDef).success, + ) + .map((labelValDef) => + interpretLabelValueDefinition(labelValDef, labelerView.creator.did), + ) +} diff --git a/packages/api/src/rich-text/detection.ts b/packages/api/src/rich-text/detection.ts index 25edcd9e57b..22c5db1b087 100644 --- a/packages/api/src/rich-text/detection.ts +++ b/packages/api/src/rich-text/detection.ts @@ -1,6 +1,12 @@ import TLDs from 'tlds' import { AppBskyRichtextFacet } from '../client' import { UnicodeString } from './unicode' +import { + URL_REGEX, + MENTION_REGEX, + TAG_REGEX, + TRAILING_PUNCTUATION_REGEX, +} from './util' export type Facet = AppBskyRichtextFacet.Main @@ -9,7 +15,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { const facets: Facet[] = [] { // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g + const re = MENTION_REGEX while ((match = re.exec(text.utf16))) { if (!isValidDomain(match[3]) && !match[3].endsWith('.test')) { continue // probably not a handle @@ -33,8 +39,7 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } { // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + const re = URL_REGEX while ((match = re.exec(text.utf16))) { let uri = match[2] if (!uri.startsWith('http')) { @@ -70,27 +75,28 @@ export function detectFacets(text: UnicodeString): Facet[] | undefined { } } { - const re = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const re = TAG_REGEX while ((match = re.exec(text.utf16))) { - let [tag] = match - const hasLeadingSpace = /^\s/.test(tag) + let [, leading, tag] = match - tag = tag.trim().replace(/\p{P}+$/gu, '') // strip ending punctuation + if (!tag) continue - // inclusive of #, max of 64 chars - if (tag.length > 66) continue + // strip ending punctuation and any spaces + tag = tag.trim().replace(TRAILING_PUNCTUATION_REGEX, '') - const index = match.index + (hasLeadingSpace ? 1 : 0) + if (tag.length === 0 || tag.length > 64) continue + + const index = match.index + leading.length facets.push({ index: { byteStart: text.utf16IndexToUtf8Index(index), - byteEnd: text.utf16IndexToUtf8Index(index + tag.length), // inclusive of last char + byteEnd: text.utf16IndexToUtf8Index(index + 1 + tag.length), }, features: [ { $type: 'app.bsky.richtext.facet#tag', - tag: tag.replace(/^#/, ''), + tag: tag, }, ], }) diff --git a/packages/api/src/rich-text/util.ts b/packages/api/src/rich-text/util.ts new file mode 100644 index 00000000000..ab50c66212d --- /dev/null +++ b/packages/api/src/rich-text/util.ts @@ -0,0 +1,11 @@ +export const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g +export const URL_REGEX = + /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim +export const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu + +/** + * `\ufe0f` emoji modifier + * `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete) + */ +export const TAG_REGEX = + /(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 3d6f73baa33..a633ff79a33 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,4 +1,10 @@ -import { LabelPreference } from './moderation/types' +import { AppBskyActorDefs } from './client' +import { ModerationPrefs } from './moderation/types' + +/** + * Supported proxy targets + */ +export type AtprotoServiceType = 'atproto_labeler' /** * Used by the PersistSessionHandler to indicate what change occurred @@ -66,15 +72,10 @@ export type AtpAgentFetchHandler = ( * AtpAgent global config opts */ export interface AtpAgentGlobalOpts { - fetch: AtpAgentFetchHandler + fetch?: AtpAgentFetchHandler + appLabelers?: string[] } -/** - * 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 */ @@ -115,8 +116,7 @@ export interface BskyPreferences { } feedViewPrefs: Record threadViewPrefs: BskyThreadViewPreference - adultContentEnabled: boolean - contentLabels: Record + moderationPrefs: ModerationPrefs birthDate: Date | undefined interests: BskyInterestsPreference } diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts new file mode 100644 index 00000000000..58d60a5e48b --- /dev/null +++ b/packages/api/src/util.ts @@ -0,0 +1,6 @@ +export function sanitizeMutedWordValue(value: string) { + return value + .trim() + .replace(/^#(?!\ufe0f)/, '') + .replace(/[\r\n\u00AD\u2060\u200D\u200C\u200B]+/, '') +} diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index cff4e3517a8..f618c0a5bc9 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -1,13 +1,16 @@ import assert from 'assert' +import getPort from 'get-port' import { defaultFetchHandler } from '@atproto/xrpc' import { AtpAgent, AtpAgentFetchHandlerResponse, AtpSessionEvent, AtpSessionData, + BSKY_LABELER_DID, } from '..' import { TestNetworkNoAppView } from '@atproto/dev-env' import { getPdsEndpoint, isValidDidDoc } from '@atproto/common-web' +import { createHeaderEchoServer } from './util/echo-server' describe('agent', () => { let network: TestNetworkNoAppView @@ -25,6 +28,14 @@ describe('agent', () => { await network.close() }) + it('clones correctly', () => { + const persistSession = (_evt: AtpSessionEvent, _sess?: AtpSessionData) => {} + const agent = new AtpAgent({ service: network.pds.url, persistSession }) + const agent2 = agent.clone() + expect(agent2 instanceof AtpAgent).toBeTruthy() + expect(agent.service).toEqual(agent2.service) + }) + it('creates a new session on account creation.', async () => { const events: string[] = [] const sessions: (AtpSessionData | undefined)[] = [] @@ -439,6 +450,7 @@ describe('agent', () => { expect(originalHandlerCallCount).toEqual(1) agent.setPersistSessionHandler(newPersistSession) + agent.session = undefined await agent.createAccount({ handle: 'user8.test', @@ -478,6 +490,81 @@ describe('agent', () => { expect(sessions[0]).toEqual(undefined) }) }) + + describe('App labelers header', () => { + it('adds the labelers header as expected', async () => { + const port = await getPort() + const server = await createHeaderEchoServer(port) + const agent = new AtpAgent({ service: `http://localhost:${port}` }) + const agent2 = new AtpAgent({ service: `http://localhost:${port}` }) + + const res1 = await agent.com.atproto.server.describeServer() + expect(res1.data['atproto-accept-labelers']).toEqual( + `${BSKY_LABELER_DID};redact`, + ) + + AtpAgent.configure({ appLabelers: ['did:plc:test1', 'did:plc:test2'] }) + const res2 = await agent.com.atproto.server.describeServer() + expect(res2.data['atproto-accept-labelers']).toEqual( + 'did:plc:test1;redact, did:plc:test2;redact', + ) + const res3 = await agent2.com.atproto.server.describeServer() + expect(res3.data['atproto-accept-labelers']).toEqual( + 'did:plc:test1;redact, did:plc:test2;redact', + ) + AtpAgent.configure({ appLabelers: [BSKY_LABELER_DID] }) + + await new Promise((r) => server.close(r)) + }) + }) + + describe('configureLabelersHeader', () => { + it('adds the labelers header as expected', async () => { + const port = await getPort() + const server = await createHeaderEchoServer(port) + const agent = new AtpAgent({ service: `http://localhost:${port}` }) + + agent.configureLabelersHeader(['did:plc:test1']) + const res1 = await agent.com.atproto.server.describeServer() + expect(res1.data['atproto-accept-labelers']).toEqual( + `${BSKY_LABELER_DID};redact, did:plc:test1`, + ) + + agent.configureLabelersHeader(['did:plc:test1', 'did:plc:test2']) + const res2 = await agent.com.atproto.server.describeServer() + expect(res2.data['atproto-accept-labelers']).toEqual( + `${BSKY_LABELER_DID};redact, did:plc:test1, did:plc:test2`, + ) + + await new Promise((r) => server.close(r)) + }) + }) + + describe('configureProxyHeader', () => { + it('adds the proxy header as expected', async () => { + const port = await getPort() + const server = await createHeaderEchoServer(port) + const agent = new AtpAgent({ service: `http://localhost:${port}` }) + + const res1 = await agent.com.atproto.server.describeServer() + expect(res1.data['atproto-proxy']).toBeFalsy() + + agent.configureProxyHeader('atproto_labeler', 'did:plc:test1') + const res2 = await agent.com.atproto.server.describeServer() + expect(res2.data['atproto-proxy']).toEqual( + 'did:plc:test1#atproto_labeler', + ) + + const res3 = await agent + .withProxy('atproto_labeler', 'did:plc:test2') + .com.atproto.server.describeServer() + expect(res3.data['atproto-proxy']).toEqual( + 'did:plc:test2#atproto_labeler', + ) + + await new Promise((r) => server.close(r)) + }) + }) }) const createPost = async (agent: AtpAgent) => { diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 5f850b19e91..e0713597a3d 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -1,5 +1,10 @@ import { TestNetworkNoAppView } from '@atproto/dev-env' -import { BskyAgent, ComAtprotoRepoPutRecord, AppBskyActorProfile } from '..' +import { + BskyAgent, + ComAtprotoRepoPutRecord, + AppBskyActorProfile, + DEFAULT_LABEL_SETTINGS, +} from '..' describe('agent', () => { let network: TestNetworkNoAppView @@ -28,6 +33,13 @@ describe('agent', () => { } } + it('clones correctly', () => { + const agent = new BskyAgent({ service: network.pds.url }) + const agent2 = agent.clone() + expect(agent2 instanceof BskyAgent).toBeTruthy() + expect(agent.service).toEqual(agent2.service) + }) + it('upsertProfile correctly creates and updates profiles.', async () => { const agent = new BskyAgent({ service: network.pds.url }) @@ -218,15 +230,25 @@ describe('agent', () => { password: 'password', }) + const DEFAULT_LABELERS = BskyAgent.appLabelers.map((did) => ({ + did, + labels: {}, + })) + await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, - adultContentEnabled: false, - contentLabels: {}, + moderationPrefs: { + adultContentEnabled: false, + labels: DEFAULT_LABEL_SETTINGS, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], + }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -244,13 +266,18 @@ 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, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], + }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -268,13 +295,18 @@ 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, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], + }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -289,18 +321,21 @@ describe('agent', () => { }, }) - 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' }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -315,20 +350,25 @@ describe('agent', () => { }, }) - 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -349,16 +389,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -379,16 +425,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -409,16 +461,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -439,16 +497,22 @@ describe('agent', () => { pinned: [], saved: [], }, - adultContentEnabled: false, - contentLabels: { - impersonation: 'hide', - spam: 'ignore', + moderationPrefs: { + adultContentEnabled: false, + labels: { + ...DEFAULT_LABEL_SETTINGS, + misinfo: 'hide', + spam: 'ignore', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -469,16 +533,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -505,16 +575,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -535,16 +611,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: undefined, feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -565,16 +647,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -595,16 +683,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -625,16 +719,22 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -655,23 +755,29 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -692,23 +798,29 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -729,23 +841,29 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -766,23 +884,29 @@ 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', + }, + labelers: DEFAULT_LABELERS, + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -811,24 +935,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#labelersPref', + labelers: [ + { + did: 'did:plc:first-labeler', + }, + ], + }, + { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [ + { + did: 'did:plc:first-labeler', + }, + { + did: 'did:plc:other', + }, + ], + }, { $type: 'app.bsky.actor.defs#adultContentPref', enabled: true, @@ -869,7 +1012,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -878,7 +1021,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -900,15 +1043,31 @@ describe('agent', () => { pinned: [], saved: [], }, - adultContentEnabled: true, - contentLabels: { - nsfw: 'warn', + moderationPrefs: { + adultContentEnabled: true, + labels: { + ...DEFAULT_LABEL_SETTINGS, + porn: 'warn', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + { + did: 'did:plc:other', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -929,15 +1088,31 @@ describe('agent', () => { pinned: [], saved: [], }, - adultContentEnabled: false, - contentLabels: { - nsfw: 'warn', + moderationPrefs: { + adultContentEnabled: false, + labels: { + ...DEFAULT_LABEL_SETTINGS, + porn: 'warn', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + { + did: 'did:plc:other', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -952,21 +1127,80 @@ describe('agent', () => { }, }) - await agent.setContentLabelPref('nsfw', 'hide') + await agent.setContentLabelPref('porn', 'ignore') await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: [], saved: [], }, - adultContentEnabled: false, - contentLabels: { - nsfw: 'hide', + moderationPrefs: { + adultContentEnabled: false, + labels: { + ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', + porn: 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + { + did: 'did:plc:other', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 10, + hideReposts: true, + hideQuotePosts: true, + }, + }, + threadViewPrefs: { + sort: 'newest', + prioritizeFollowedUsers: false, + }, + interests: { + tags: [], + }, + }) + + await agent.removeLabeler('did:plc:other') + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: [], + saved: [], + }, + moderationPrefs: { + adultContentEnabled: false, + labels: { + ...DEFAULT_LABEL_SETTINGS, + nsfw: 'ignore', + porn: 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + birthDate: new Date('2021-09-11T18:05:42.556Z'), + feedViewPrefs: { + home: { + hideReplies: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -987,15 +1221,28 @@ 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, + nsfw: 'ignore', + porn: 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2021-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1016,15 +1263,28 @@ 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, + nsfw: 'ignore', + porn: 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1041,7 +1301,7 @@ describe('agent', () => { await agent.setFeedViewPrefs('home', { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -1056,15 +1316,28 @@ 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, + nsfw: 'ignore', + porn: 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:first-labeler', + labels: {}, + }, + ], + mutedWords: [], + hiddenPosts: [], }, birthDate: new Date('2023-09-11T18:05:42.556Z'), feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -1086,10 +1359,23 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#adultContentPref', enabled: false, }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'porn', + visibility: 'ignore', + }, { $type: 'app.bsky.actor.defs#contentLabelPref', label: 'nsfw', - visibility: 'hide', + visibility: 'ignore', + }, + { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [ + { + did: 'did:plc:first-labeler', + }, + ], }, { $type: 'app.bsky.actor.defs#savedFeedsPref', @@ -1105,7 +1391,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -1118,6 +1404,277 @@ describe('agent', () => { ].sort(byType), ) }) + + describe('muted words', () => { + let agent: BskyAgent + const mutedWords = [ + { value: 'both', targets: ['content', 'tag'] }, + { value: 'content', targets: ['content'] }, + { value: 'tag', targets: ['tag'] }, + { value: 'tag_then_both', targets: ['tag'] }, + { value: 'tag_then_content', targets: ['tag'] }, + { value: 'tag_then_none', targets: ['tag'] }, + ] + + beforeAll(async () => { + agent = new BskyAgent({ service: network.pds.url }) + await agent.createAccount({ + handle: 'user7.test', + email: 'user7@test.com', + password: 'password', + }) + }) + + it('upsertMutedWords', async () => { + await agent.upsertMutedWords(mutedWords) + await agent.upsertMutedWords(mutedWords) // double + await expect(agent.getPreferences()).resolves.toHaveProperty( + 'moderationPrefs.mutedWords', + mutedWords, + ) + }) + + it('upsertMutedWords with #', async () => { + await agent.upsertMutedWords([ + { value: 'hashtag', targets: ['content'] }, + ]) + // is sanitized to `hashtag` + await agent.upsertMutedWords([{ value: '#hashtag', targets: ['tag'] }]) + + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === '#hashtag')).toBeFalsy() + // merged with existing + expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({ + value: 'hashtag', + targets: ['content', 'tag'], + }) + // only one added + expect(mutedWords.filter((m) => m.value === 'hashtag').length).toBe(1) + }) + + it('updateMutedWord', async () => { + await agent.updateMutedWord({ + value: 'tag_then_content', + targets: ['content'], + }) + await agent.updateMutedWord({ + value: 'tag_then_both', + targets: ['content', 'tag'], + }) + await agent.updateMutedWord({ value: 'tag_then_none', targets: [] }) + await agent.updateMutedWord({ value: 'no_exist', targets: ['tag'] }) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect( + mutedWords.find((m) => m.value === 'tag_then_content'), + ).toHaveProperty('targets', ['content']) + expect( + mutedWords.find((m) => m.value === 'tag_then_both'), + ).toHaveProperty('targets', ['content', 'tag']) + expect( + mutedWords.find((m) => m.value === 'tag_then_none'), + ).toHaveProperty('targets', []) + expect(mutedWords.find((m) => m.value === 'no_exist')).toBeFalsy() + }) + + it('updateMutedWord with #, does not update', async () => { + await agent.upsertMutedWords([ + { + value: '#just_a_tag', + targets: ['tag'], + }, + ]) + await agent.updateMutedWord({ + value: '#just_a_tag', + targets: ['tag', 'content'], + }) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({ + value: 'just_a_tag', + targets: ['tag'], + }) + }) + + it('removeMutedWord', async () => { + await agent.removeMutedWord({ value: 'tag_then_content', targets: [] }) + await agent.removeMutedWord({ value: 'tag_then_both', targets: [] }) + await agent.removeMutedWord({ value: 'tag_then_none', targets: [] }) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect( + mutedWords.find((m) => m.value === 'tag_then_content'), + ).toBeFalsy() + expect(mutedWords.find((m) => m.value === 'tag_then_both')).toBeFalsy() + expect(mutedWords.find((m) => m.value === 'tag_then_none')).toBeFalsy() + }) + + it('removeMutedWord with #, no match, no removal', async () => { + await agent.removeMutedWord({ value: '#hashtag', targets: [] }) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + // was inserted with #hashtag, but we don't sanitize on remove + expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy() + }) + + it('single-hash #', async () => { + const prev = (await agent.getPreferences()).moderationPrefs + const length = prev.mutedWords.length + await agent.upsertMutedWords([{ value: '#', targets: [] }]) + const end = (await agent.getPreferences()).moderationPrefs + + // sanitized to empty string, not inserted + expect(end.mutedWords.length).toEqual(length) + }) + + it('multi-hash ##', async () => { + await agent.upsertMutedWords([{ value: '##', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === '#')).toBeTruthy() + }) + + it('multi-hash ##hashtag', async () => { + await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }]) + const a = (await agent.getPreferences()).moderationPrefs + + expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy() + + await agent.removeMutedWord({ value: '#hashtag', targets: [] }) + const b = (await agent.getPreferences()).moderationPrefs + + expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy() + }) + + it('hash emoji #️⃣', async () => { + await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '#️⃣', targets: [] }) + const end = (await agent.getPreferences()).moderationPrefs + + expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() + }) + + it('hash emoji ##️⃣', async () => { + await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '#️⃣', targets: [] }) + const end = (await agent.getPreferences()).moderationPrefs + + expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() + }) + + it('hash emoji ###️⃣', async () => { + await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy() + + await agent.removeMutedWord({ value: '##️⃣', targets: [] }) + const end = (await agent.getPreferences()).moderationPrefs + + expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy() + }) + + it(`apostrophe: Bluesky's`, async () => { + await agent.upsertMutedWords([{ value: `Bluesky's`, targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === `Bluesky's`)).toBeTruthy() + }) + + describe(`invalid characters`, () => { + it('zero width space', async () => { + const prev = (await agent.getPreferences()).moderationPrefs + const length = prev.mutedWords.length + await agent.upsertMutedWords([{ value: '#​', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.length).toEqual(length) + }) + + it('newline', async () => { + await agent.upsertMutedWords([ + { value: 'test value\n with newline', targets: [] }, + ]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect( + mutedWords.find((m) => m.value === 'test value with newline'), + ).toBeTruthy() + }) + + it('newline(s)', async () => { + await agent.upsertMutedWords([ + { value: 'test value\n\r with newline', targets: [] }, + ]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect( + mutedWords.find((m) => m.value === 'test value with newline'), + ).toBeTruthy() + }) + + it('empty space', async () => { + await agent.upsertMutedWords([{ value: ' ', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy() + }) + + it('leading/trailing space', async () => { + await agent.upsertMutedWords([{ value: ' trim ', targets: [] }]) + const { mutedWords } = (await agent.getPreferences()).moderationPrefs + + expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy() + }) + }) + }) + + describe('hidden posts', () => { + let agent: BskyAgent + const postUri = 'at://did:plc:fake/app.bsky.feed.post/fake' + + beforeAll(async () => { + agent = new BskyAgent({ service: network.pds.url }) + await agent.createAccount({ + handle: 'user8.test', + email: 'user8@test.com', + password: 'password', + }) + }) + + it('hidePost', async () => { + await agent.hidePost(postUri) + await agent.hidePost(postUri) // double, should dedupe + await expect(agent.getPreferences()).resolves.toHaveProperty( + 'moderationPrefs.hiddenPosts', + [postUri], + ) + }) + + it('unhidePost', async () => { + await agent.unhidePost(postUri) + await expect(agent.getPreferences()).resolves.toHaveProperty( + 'moderationPrefs.hiddenPosts', + [], + ) + // no issues calling a second time + await agent.unhidePost(postUri) + await expect(agent.getPreferences()).resolves.toHaveProperty( + 'moderationPrefs.hiddenPosts', + [], + ) + }) + }) + + // end }) }) diff --git a/packages/api/tests/moderation-behaviors.test.ts b/packages/api/tests/moderation-behaviors.test.ts new file mode 100644 index 00000000000..686956fd6ae --- /dev/null +++ b/packages/api/tests/moderation-behaviors.test.ts @@ -0,0 +1,914 @@ +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: { + avatar: ['blur', 'noOverride'], + banner: ['blur', 'noOverride'], + displayName: ['blur', 'noOverride'], + }, + }, + "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'], + }, + }, + "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 ('!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: { + avatar: ['blur'], + banner: ['blur'], + }, + }, + "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'], + }, + }, + "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: [], + avatar: ['blur', 'noOverride'], + banner: ['blur', 'noOverride'], + contentList: [], + }, + }, + '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: [], + }, + }, + '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: ['blur'], + profileView: ['blur'], + avatar: ['blur', 'noOverride'], + banner: ['blur', 'noOverride'], + displayName: ['blur', 'noOverride'], + contentList: ['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..3e051fb0498 --- /dev/null +++ b/packages/api/tests/moderation-custom-labels.test.ts @@ -0,0 +1,333 @@ +import { + moderateProfile, + moderatePost, + mock, + ModerationOpts, + InterpretedLabelValueDefinition, + interpretLabelValueDefinition, +} from '../src' +import './util/moderation-behavior' + +interface ScenarioResult { + profileList?: string[] + profileView?: string[] + avatar?: string[] + banner?: string[] + displayName?: string[] + contentList?: string[] + contentView?: string[] + contentMedia?: string[] +} + +interface Scenario { + blurs: 'content' | 'media' | 'none' + severity: 'alert' | 'inform' | 'none' + account: ScenarioResult + profile: ScenarioResult + post: ScenarioResult +} + +const TESTS: Scenario[] = [ + { + blurs: 'content', + severity: 'alert', + account: { + profileList: ['filter', 'alert'], + profileView: ['alert'], + contentList: ['filter', 'blur'], + contentView: ['alert'], + }, + profile: { + profileList: ['alert'], + profileView: ['alert'], + }, + post: { + contentList: ['filter', 'blur'], + contentView: ['alert'], + }, + }, + { + blurs: 'content', + severity: 'inform', + account: { + profileList: ['filter', 'inform'], + profileView: ['inform'], + contentList: ['filter', 'blur'], + contentView: ['inform'], + }, + profile: { + profileList: ['inform'], + profileView: ['inform'], + }, + post: { + contentList: ['filter', 'blur'], + contentView: ['inform'], + }, + }, + { + blurs: 'content', + severity: 'none', + account: { + profileList: ['filter'], + profileView: [], + contentList: ['filter', 'blur'], + contentView: [], + }, + profile: { + profileList: [], + profileView: [], + }, + post: { + contentList: ['filter', 'blur'], + contentView: [], + }, + }, + + { + blurs: 'media', + severity: 'alert', + account: { + profileList: ['filter', 'alert'], + profileView: ['alert'], + avatar: ['blur'], + banner: ['blur'], + contentList: ['filter'], + }, + profile: { + profileList: ['alert'], + profileView: ['alert'], + avatar: ['blur'], + banner: ['blur'], + }, + post: { + contentList: ['filter'], + contentMedia: ['blur'], + }, + }, + { + blurs: 'media', + severity: 'inform', + account: { + profileList: ['filter', 'inform'], + profileView: ['inform'], + avatar: ['blur'], + banner: ['blur'], + contentList: ['filter'], + }, + profile: { + profileList: ['inform'], + profileView: ['inform'], + avatar: ['blur'], + banner: ['blur'], + }, + post: { + contentList: ['filter'], + contentMedia: ['blur'], + }, + }, + { + blurs: 'media', + severity: 'none', + account: { + profileList: ['filter'], + avatar: ['blur'], + banner: ['blur'], + contentList: ['filter'], + }, + profile: { + avatar: ['blur'], + banner: ['blur'], + }, + post: { + contentList: ['filter'], + contentMedia: ['blur'], + }, + }, + + { + blurs: 'none', + severity: 'alert', + account: { + profileList: ['filter', 'alert'], + profileView: ['alert'], + contentList: ['filter', 'alert'], + contentView: ['alert'], + }, + profile: { + profileList: ['alert'], + profileView: ['alert'], + }, + post: { + contentList: ['filter', 'alert'], + contentView: ['alert'], + }, + }, + { + blurs: 'none', + severity: 'inform', + account: { + profileList: ['filter', 'inform'], + profileView: ['inform'], + contentList: ['filter', 'inform'], + contentView: ['inform'], + }, + profile: { + profileList: ['inform'], + profileView: ['inform'], + }, + post: { + contentList: ['filter', 'inform'], + contentView: ['inform'], + }, + }, + { + blurs: 'none', + severity: 'none', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, +] + +describe('Moderation: custom labels', () => { + const scenarios = TESTS.flatMap((test) => [ + { + blurs: test.blurs, + severity: test.severity, + target: 'post', + expected: test.post, + }, + { + blurs: test.blurs, + severity: test.severity, + target: 'profile', + expected: test.profile, + }, + { + blurs: test.blurs, + severity: test.severity, + target: 'account', + expected: test.account, + }, + ]) + it.each(scenarios)( + 'blurs=$blurs, severity=$severity, target=$target', + ({ blurs, severity, target, expected }) => { + let 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: {}, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { custom: 'hide' }, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + labelDefs: { + 'did:web:labeler.test': [makeCustomLabel(blurs, severity)], + }, + } +} + +function makeCustomLabel( + blurs: string, + severity: string, +): InterpretedLabelValueDefinition { + return interpretLabelValueDefinition( + { + identifier: 'custom', + blurs, + severity, + defaultSetting: 'warn', + locales: [], + }, + 'did:web:labeler.test', + ) +} diff --git a/packages/api/tests/moderation-mutewords.test.ts b/packages/api/tests/moderation-mutewords.test.ts new file mode 100644 index 00000000000..5416152aecb --- /dev/null +++ b/packages/api/tests/moderation-mutewords.test.ts @@ -0,0 +1,769 @@ +import { RichText, mock, moderatePost } from '../src/' + +import { hasMutedWord } from '../src/moderation/mutewords' + +describe(`hasMutedWord`, () => { + describe(`tags`, () => { + it(`match: outline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'outlineTag', targets: ['tag'] }], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) + + expect(match).toBe(true) + }) + + it(`match: inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'inlineTag', targets: ['tag'] }], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) + + expect(match).toBe(true) + }) + + it(`match: content target matches inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'inlineTag', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) + + expect(match).toBe(true) + }) + + it(`no match: only tag targets`, () => { + const rt = new RichText({ + text: `This is a post`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'inlineTag', targets: ['tag'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + }) + + describe(`early exits`, () => { + it(`match: single character 希`, () => { + /** + * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c + */ + const rt = new RichText({ + text: `改善希望です`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: '希', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: single char with length > 1 ☠︎`, () => { + const rt = new RichText({ + text: `Idk why ☠︎ but maybe`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: '☠︎', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`no match: long muted word, short post`, () => { + const rt = new RichText({ + text: `hey`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'politics', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + + it(`match: exact text`, () => { + const rt = new RichText({ + text: `javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'javascript', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`general content`, () => { + it(`match: word within post`, () => { + const rt = new RichText({ + text: `This is a post about javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'javascript', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`no match: partial word`, () => { + const rt = new RichText({ + text: `Use your brain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'ai', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + + it(`match: multiline`, () => { + const rt = new RichText({ + text: `Use your\n\tbrain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: 'brain', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: :)`, () => { + const rt = new RichText({ + text: `So happy :)`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: `:)`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`punctuation semi-fuzzy`, () => { + describe(`yay!`, () => { + const rt = new RichText({ + text: `We're federating, yay!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: yay!`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'yay!', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: yay`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'yay', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`y!ppee!!`, () => { + const rt = new RichText({ + text: `We're federating, y!ppee!!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: y!ppee`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'y!ppee', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + // single exclamation point, source has double + it(`no match: y!ppee!`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'y!ppee!', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`apostrophes: Bluesky's`, () => { + const rt = new RichText({ + text: `Yay, Bluesky's mutewords work`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: Bluesky's`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `Bluesky's`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: Bluesky`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'Bluesky', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: bluesky`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'bluesky', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: blueskys`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'blueskys', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`Why so S@assy?`, () => { + const rt = new RichText({ + text: `Why so S@assy?`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: S@assy`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'S@assy', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: s@assy`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 's@assy', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`New York Times`, () => { + const rt = new RichText({ + text: `New York Times`, + }) + rt.detectFacetsWithoutResolution() + + // case insensitive + it(`match: new york times`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'new york times', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`!command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot !command`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: !command`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `!command`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: command`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `command`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`no match: !command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot command`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{ value: `!command`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + }) + + describe(`e/acc`, () => { + const rt = new RichText({ + text: `I'm e/acc pilled`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: e/acc`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `e/acc`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: acc`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `acc`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`super-bad`, () => { + const rt = new RichText({ + text: `I'm super-bad`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: super-bad`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `super-bad`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: super`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `super`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: bad`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `bad`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: super bad`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `super bad`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: superbad`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `superbad`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`idk_what_this_would_be`, () => { + const rt = new RichText({ + text: `Weird post with idk_what_this_would_be`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: idk what this would be`, () => { + const match = hasMutedWord({ + mutedWords: [ + { value: `idk what this would be`, targets: ['content'] }, + ], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`no match: idk what this would be for`, () => { + // extra word + const match = hasMutedWord({ + mutedWords: [ + { value: `idk what this would be for`, targets: ['content'] }, + ], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(false) + }) + + it(`match: idk`, () => { + // extra word + const match = hasMutedWord({ + mutedWords: [{ value: `idk`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: idkwhatthiswouldbe`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `idkwhatthiswouldbe`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`parentheses`, () => { + const rt = new RichText({ + text: `Post with context(iykyk)`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: context(iykyk)`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `context(iykyk)`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: context`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `context`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: iykyk`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `iykyk`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: (iykyk)`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `(iykyk)`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + + describe(`🦋`, () => { + const rt = new RichText({ + text: `Post with 🦋`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: 🦋`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: `🦋`, targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`phrases`, () => { + describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { + const rt = new RichText({ + text: `I like turtles, or how I learned to stop worrying and love the internet.`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: stop worrying`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'stop worrying', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + + it(`match: turtles, or how`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'turtles, or how', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`languages without spaces`, () => { + // I love turtles, or how I learned to stop worrying and love the internet + describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => { + const rt = new RichText({ + text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, + }) + rt.detectFacetsWithoutResolution() + + // internet + it(`match: インターネット`, () => { + const match = hasMutedWord({ + mutedWords: [{ value: 'インターネット', targets: ['content'] }], + text: rt.text, + facets: rt.facets, + outlineTags: [], + languages: ['ja'], + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`doesn't mute own post`, () => { + it(`does mute if it isn't own post`, () => { + const res = moderatePost( + mock.postView({ + record: mock.post({ + text: 'Mute words!', + }), + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [], + }), + { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: false, + labels: {}, + labelers: [], + mutedWords: [{ value: 'words', targets: ['content'] }], + hiddenPosts: [], + }, + labelDefs: {}, + }, + ) + expect(res.causes[0].type).toBe('mute-word') + }) + + it(`doesn't mute own post when muted word is in text`, () => { + const res = moderatePost( + mock.postView({ + record: mock.post({ + text: 'Mute words!', + }), + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [], + }), + { + userDid: 'did:web:bob.test', + prefs: { + adultContentEnabled: false, + labels: {}, + labelers: [], + mutedWords: [{ value: 'words', targets: ['content'] }], + hiddenPosts: [], + }, + labelDefs: {}, + }, + ) + expect(res.causes.length).toBe(0) + }) + + it(`doesn't mute own post when muted word is in tags`, () => { + const rt = new RichText({ + text: `Mute #words!`, + }) + const res = moderatePost( + mock.postView({ + record: mock.post({ + text: rt.text, + facets: rt.facets, + }), + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [], + }), + { + userDid: 'did:web:bob.test', + prefs: { + adultContentEnabled: false, + labels: {}, + labelers: [], + mutedWords: [{ value: 'words', targets: ['tags'] }], + hiddenPosts: [], + }, + labelDefs: {}, + }, + ) + expect(res.causes.length).toBe(0) + }) + }) +}) diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts new file mode 100644 index 00000000000..e8a7c86cc1d --- /dev/null +++ b/packages/api/tests/moderation-prefs.test.ts @@ -0,0 +1,341 @@ +import { TestNetworkNoAppView } from '@atproto/dev-env' +import { BskyAgent, 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: 'porn', + visibility: 'show', + }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'nudity', + visibility: 'show', + }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'sexual', + visibility: 'show', + }, + { + $type: 'app.bsky.actor.defs#contentLabelPref', + label: 'graphic-media', + visibility: 'show', + }, + ], + }) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { + pinned: undefined, + saved: undefined, + }, + interests: { tags: [] }, + moderationPrefs: { + adultContentEnabled: false, + labels: { + porn: 'ignore', + nudity: 'ignore', + sexual: 'ignore', + 'graphic-media': 'ignore', + }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + ], + hiddenPosts: [], + mutedWords: [], + }, + birthDate: undefined, + feedViewPrefs: { + home: { + hideQuotePosts: false, + hideReplies: false, + hideRepliesByLikeCount: 0, + hideRepliesByUnfollowed: true, + hideReposts: false, + }, + }, + threadViewPrefs: { + prioritizeFollowedUsers: true, + sort: 'oldest', + }, + }) + }) + + 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.addLabeler('did:plc:other') + expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { pinned: undefined, saved: undefined }, + interests: { tags: [] }, + moderationPrefs: { + adultContentEnabled: false, + labels: DEFAULT_LABEL_SETTINGS, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:other', + labels: {}, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) + + await agent.removeLabeler('did:plc:other') + expect(agent.labelersHeader).toStrictEqual([]) + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { pinned: undefined, saved: undefined }, + interests: { tags: [] }, + moderationPrefs: { + adultContentEnabled: false, + labels: DEFAULT_LABEL_SETTINGS, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + ], + hiddenPosts: [], + mutedWords: [], + }, + birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + expect(agent.labelersHeader).toStrictEqual([]) + }) + + 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.addLabeler('did:plc:other') + await agent.setContentLabelPref('porn', 'ignore') + await agent.setContentLabelPref('porn', 'hide', 'did:plc:other') + await agent.setContentLabelPref('x-custom', 'warn', 'did:plc:other') + + await expect(agent.getPreferences()).resolves.toStrictEqual({ + feeds: { pinned: undefined, saved: undefined }, + interests: { tags: [] }, + moderationPrefs: { + adultContentEnabled: false, + labels: { ...DEFAULT_LABEL_SETTINGS, porn: 'ignore', nsfw: 'ignore' }, + labelers: [ + ...BskyAgent.appLabelers.map((did) => ({ did, labels: {} })), + { + did: 'did:plc:other', + labels: { + porn: 'hide', + 'x-custom': 'warn', + }, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + birthDate: undefined, + feedViewPrefs: { + home: { + hideReplies: false, + hideRepliesByUnfollowed: true, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + }, + }, + threadViewPrefs: { + sort: 'oldest', + prioritizeFollowedUsers: true, + }, + }) + }) + + it(`updates label pref`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user8.test', + email: 'user8@test.com', + password: 'password', + }) + + await agent.addLabeler('did:plc:other') + await agent.setContentLabelPref('porn', 'ignore') + await agent.setContentLabelPref('porn', 'ignore', 'did:plc:other') + await agent.setContentLabelPref('porn', 'hide') + await agent.setContentLabelPref('porn', 'hide', 'did:plc:other') + + const { moderationPrefs } = await agent.getPreferences() + const labeler = moderationPrefs.labelers.find( + (l) => l.did === 'did:plc:other', + ) + + expect(moderationPrefs.labels.porn).toEqual('hide') + expect(labeler?.labels?.porn).toEqual('hide') + }) + + it(`double-write for legacy: 'graphic-media' in sync with 'gore'`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user9.test', + email: 'user9@test.com', + password: 'password', + }) + + await agent.setContentLabelPref('graphic-media', 'hide') + const a = await agent.getPreferences() + + expect(a.moderationPrefs.labels.gore).toEqual('hide') + expect(a.moderationPrefs.labels['graphic-media']).toEqual('hide') + + await agent.setContentLabelPref('graphic-media', 'warn') + const b = await agent.getPreferences() + + expect(b.moderationPrefs.labels.gore).toEqual('warn') + expect(b.moderationPrefs.labels['graphic-media']).toEqual('warn') + }) + + it(`double-write for legacy: 'porn' in sync with 'nsfw'`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user10.test', + email: 'user10@test.com', + password: 'password', + }) + + await agent.setContentLabelPref('porn', 'hide') + const a = await agent.getPreferences() + + expect(a.moderationPrefs.labels.nsfw).toEqual('hide') + expect(a.moderationPrefs.labels.porn).toEqual('hide') + + await agent.setContentLabelPref('porn', 'warn') + const b = await agent.getPreferences() + + expect(b.moderationPrefs.labels.nsfw).toEqual('warn') + expect(b.moderationPrefs.labels.porn).toEqual('warn') + }) + + it(`double-write for legacy: 'sexual' in sync with 'suggestive'`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user11.test', + email: 'user11@test.com', + password: 'password', + }) + + await agent.setContentLabelPref('sexual', 'hide') + const a = await agent.getPreferences() + + expect(a.moderationPrefs.labels.sexual).toEqual('hide') + expect(a.moderationPrefs.labels.suggestive).toEqual('hide') + + await agent.setContentLabelPref('sexual', 'warn') + const b = await agent.getPreferences() + + expect(b.moderationPrefs.labels.sexual).toEqual('warn') + expect(b.moderationPrefs.labels.suggestive).toEqual('warn') + }) + + it(`double-write for legacy: filters out existing old label pref if double-written`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user12.test', + email: 'user12@test.com', + password: 'password', + }) + + await agent.setContentLabelPref('nsfw', 'hide') + await agent.setContentLabelPref('porn', 'hide') + const a = await agent.app.bsky.actor.getPreferences({}) + + const nsfwSettings = a.data.preferences.filter( + (pref) => pref.label === 'nsfw', + ) + expect(nsfwSettings.length).toEqual(1) + }) + + it(`remaps old values to new on read`, async () => { + const agent = new BskyAgent({ service: network.pds.url }) + + await agent.createAccount({ + handle: 'user13.test', + email: 'user13@test.com', + password: 'password', + }) + + await agent.setContentLabelPref('nsfw', 'hide') + await agent.setContentLabelPref('gore', 'hide') + await agent.setContentLabelPref('suggestive', 'hide') + const a = await agent.getPreferences() + + expect(a.moderationPrefs.labels.porn).toEqual('hide') + expect(a.moderationPrefs.labels['graphic-media']).toEqual('hide') + expect(a.moderationPrefs.labels['sexual']).toEqual('hide') + }) +}) diff --git a/packages/api/tests/moderation-quoteposts.test.ts b/packages/api/tests/moderation-quoteposts.test.ts new file mode 100644 index 00000000000..b511a6be73b --- /dev/null +++ b/packages/api/tests/moderation-quoteposts.test.ts @@ -0,0 +1,277 @@ +import { + moderateProfile, + moderatePost, + mock, + ModerationOpts, + InterpretedLabelValueDefinition, + interpretLabelValueDefinition, +} from '../src' +import './util/moderation-behavior' + +interface ScenarioResult { + profileList?: string[] + profileView?: string[] + avatar?: string[] + banner?: string[] + displayName?: string[] + contentList?: string[] + contentView?: string[] + contentMedia?: string[] +} + +interface Scenario { + blurs: 'content' | 'media' | 'none' + severity: 'alert' | 'inform' | 'none' + account: ScenarioResult + profile: ScenarioResult + post: ScenarioResult +} + +const TESTS: Scenario[] = [ + { + blurs: 'content', + severity: 'alert', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'content', + severity: 'inform', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'content', + severity: 'none', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + + { + blurs: 'media', + severity: 'alert', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'media', + severity: 'inform', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'media', + severity: 'none', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + + { + blurs: 'none', + severity: 'alert', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'none', + severity: 'inform', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, + { + blurs: 'none', + severity: 'none', + account: { + profileList: ['filter'], + contentList: ['filter'], + }, + profile: {}, + post: { + contentList: ['filter'], + }, + }, +] + +describe('Moderation: custom labels', () => { + const scenarios = TESTS.flatMap((test) => [ + { + blurs: test.blurs, + severity: test.severity, + target: 'post', + expected: test.post, + }, + { + blurs: test.blurs, + severity: test.severity, + target: 'profile', + expected: test.profile, + }, + { + blurs: test.blurs, + severity: test.severity, + target: 'account', + expected: test.account, + }, + ]) + it.each(scenarios)( + 'blurs=$blurs, severity=$severity, target=$target', + ({ blurs, severity, target, expected }) => { + let postLabels + let profileLabels + if (target === 'post') { + postLabels = [ + mock.label({ + val: 'custom', + uri: 'at://did:web:carla.test/app.bsky.feed.post/fake', + src: 'did:web:labeler.test', + }), + ] + } else if (target === 'profile') { + profileLabels = [ + mock.label({ + val: 'custom', + uri: 'at://did:web:carla.test/app.bsky.actor.profile/self', + src: 'did:web:labeler.test', + }), + ] + } else { + profileLabels = [ + mock.label({ + val: 'custom', + uri: 'did:web:carla.test', + src: 'did:web:labeler.test', + }), + ] + } + + const post = mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + embed: mock.embedRecordView({ + record: mock.post({ + text: 'Quoted post text', + }), + labels: postLabels, + author: mock.profileViewBasic({ + handle: 'carla.test', + displayName: 'Carla', + labels: profileLabels, + }), + }), + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + }) + const res = moderatePost(post, modOpts(blurs, severity)) + + expect(res.ui('profileList')).toBeModerationResult( + expected.profileList || [], + ) + expect(res.ui('profileView')).toBeModerationResult( + expected.profileView || [], + ) + expect(res.ui('avatar')).toBeModerationResult(expected.avatar || []) + expect(res.ui('banner')).toBeModerationResult(expected.banner || []) + expect(res.ui('displayName')).toBeModerationResult( + expected.displayName || [], + ) + expect(res.ui('contentList')).toBeModerationResult( + expected.contentList || [], + ) + expect(res.ui('contentView')).toBeModerationResult( + expected.contentView || [], + ) + expect(res.ui('contentMedia')).toBeModerationResult( + expected.contentMedia || [], + ) + }, + ) +}) + +function modOpts(blurs: string, severity: string): ModerationOpts { + return { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: true, + labels: {}, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { custom: 'hide' }, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + labelDefs: { + 'did:web:labeler.test': [makeCustomLabel(blurs, severity)], + }, + } +} + +function makeCustomLabel( + blurs: string, + severity: string, +): InterpretedLabelValueDefinition { + return interpretLabelValueDefinition( + { + identifier: 'custom', + blurs, + severity, + defaultSetting: 'warn', + locales: [], + }, + 'did:web:labeler.test', + ) +} diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index d9dd98086d8..d7d3f9d47bc 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -1,6 +1,11 @@ -import { moderateProfile, moderatePost } from '../src' -import { mock } from './util' +import { + moderateProfile, + moderatePost, + mock, + interpretLabelValueDefinition, +} from '../src' import './util/moderation-behavior' +import { ModerationOpts } from '../dist' describe('Moderation', () => { it('Applies self-labels on profiles according to the global preferences', () => { @@ -20,25 +25,19 @@ describe('Moderation', () => { }), { userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'hide', + prefs: { + adultContentEnabled: true, + labels: { + porn: 'hide', + }, + labelers: [], + hiddenPosts: [], + mutedWords: [], }, - labelers: [], }, ) - expect(res1.account).toBeModerationResult( - {}, - 'post content', - JSON.stringify(res1, null, 2), - ) - expect(res1.profile).toBeModerationResult( - {}, - 'post content', - JSON.stringify(res1, null, 2), - ) - expect(res1.avatar).toBeModerationResult( - { blur: true }, + expect(res1.ui('avatar')).toBeModerationResult( + ['blur'], 'post avatar', JSON.stringify(res1, null, 2), true, @@ -60,33 +59,121 @@ describe('Moderation', () => { }), { userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'ignore', + prefs: { + adultContentEnabled: true, + labels: { + porn: 'ignore', + }, + labelers: [], + hiddenPosts: [], + mutedWords: [], }, - labelers: [], }, ) - expect(res2.account).toBeModerationResult( - {}, - 'post content', - JSON.stringify(res2, null, 2), - ) - expect(res2.profile).toBeModerationResult( - {}, - 'post content', - JSON.stringify(res2, null, 2), - ) - expect(res2.avatar).toBeModerationResult( - {}, + expect(res2.ui('avatar')).toBeModerationResult( + [], 'post avatar', - JSON.stringify(res2, null, 2), + JSON.stringify(res1, null, 2), true, ) }) - it('Applies self-labels on posts according to the global preferences', () => { - // porn (hide) + it('Ignores labels from unsubscribed moderators or ignored labels for a moderator', () => { + // porn (moderator disabled) + const res1 = moderateProfile( + mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', + val: 'porn', + cts: new Date().toISOString(), + }, + ], + }), + { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: true, + labels: { + porn: 'hide', + }, + labelers: [], + hiddenPosts: [], + mutedWords: [], + }, + }, + ) + for (const k of [ + 'profileList', + 'profileView', + 'avatar', + 'banner', + 'displayName', + 'contentList', + 'contentView', + 'contentMedia', + ] as const) { + 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', + }, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { porn: 'ignore' }, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + }, + ) + for (const k of [ + 'profileList', + 'profileView', + 'avatar', + 'banner', + 'displayName', + 'contentList', + 'contentView', + 'contentMedia', + ] as const) { + expect(res2.ui(k)).toBeModerationResult( + [], + k, + JSON.stringify(res2, null, 2), + ) + } + }) + + it('Can manually apply hiding', () => { const res1 = moderatePost( mock.postView({ record: { @@ -97,43 +184,34 @@ 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: 'hide', + prefs: { + adultContentEnabled: true, + labels: {}, + labelers: [ + { + did: 'did:web:labeler.test', + labels: {}, + }, + ], + hiddenPosts: [], + mutedWords: [], }, - 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, + res1.addHidden(true) + expect(res1.ui('contentList')).toBeModerationResult( + ['filter', 'blur'], + 'contentList', ) + expect(res1.ui('contentView')).toBeModerationResult(['blur'], 'contentView') + }) - // porn (ignore) - const res2 = moderatePost( + it('Prioritizes filters and blurs correctly on merge', () => { + const res1 = moderatePost( mock.postView({ record: { text: 'Hello', @@ -145,190 +223,507 @@ describe('Moderation', () => { }), labels: [ { - src: 'did:web:bob.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', val: 'porn', cts: new Date().toISOString(), }, + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: '!hide', + cts: new Date().toISOString(), + }, ], }), { userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'ignore', + prefs: { + adultContentEnabled: true, + labels: { + porn: 'hide', + }, + labelers: [ + { + did: 'did:web:labeler.test', + labels: {}, + }, + ], + hiddenPosts: [], + mutedWords: [], }, - 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, - ) + expect((res1.ui('contentList').filters[0] as any).label.val).toBe('!hide') + expect((res1.ui('contentList').filters[1] as any).label.val).toBe('porn') + expect((res1.ui('contentList').blurs[0] as any).label.val).toBe('!hide') + expect((res1.ui('contentMedia').blurs[0] as any).label.val).toBe('porn') }) - 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 custom label definitions', () => { + const modOpts: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: true, + labels: { porn: 'warn' }, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { porn: 'warn' }, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'porn', + blurs: 'none', + severity: 'inform', + locales: [], + defaultSetting: 'warn', + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), labels: [ { src: 'did:web:labeler.test', - uri: 'at://did:web:bob.test/app.bsky.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: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { adultContentEnabled: true, - labels: { - porn: 'hide', - }, + labels: {}, labelers: [ { - labeler: { - did: 'did:web:labeler.test', - displayName: 'Labeler', - }, - labels: { - porn: 'ignore', - }, + did: 'did:web:labeler.test', + labels: {}, }, ], + hiddenPosts: [], + mutedWords: [], }, - ) - expect(res1.avatar).toBeModerationResult( - {}, - 'post avatar', - JSON.stringify(res1, null, 2), - true, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: '!hide', + blurs: 'none', + severity: 'inform', + locales: [], + defaultSetting: 'warn', + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: '!hide', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, ) - // porn (hide for labeler, ignore for global) - const res2 = moderateProfile( - mock.profileViewBasic({ - handle: 'bob.test', - displayName: 'Bob', + expect(res.ui('profileList')).toBeModerationResult([]) + expect(res.ui('profileView')).toBeModerationResult([]) + expect(res.ui('avatar')).toBeModerationResult([]) + expect(res.ui('banner')).toBeModerationResult([]) + expect(res.ui('displayName')).toBeModerationResult([]) + expect(res.ui('contentList')).toBeModerationResult([ + 'filter', + 'blur', + 'noOverride', + ]) + expect(res.ui('contentView')).toBeModerationResult(['blur', 'noOverride']) + expect(res.ui('contentMedia')).toBeModerationResult([]) + }) + + it('Ignores invalid label value names', () => { + const modOpts: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: true, + labels: {}, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { BadLabel: 'hide', 'bad/label': 'hide' }, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'BadLabel', + blurs: 'content', + severity: 'inform', + locales: [], + defaultSetting: 'warn', + }, + 'did:web:labeler.test', + ), + interpretLabelValueDefinition( + { + identifier: 'bad/label', + blurs: 'content', + severity: 'inform', + locales: [], + defaultSetting: 'warn', + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), labels: [ { src: 'did:web:labeler.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', - val: 'porn', + 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', + 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([]) + expect(res.ui('contentView')).toBeModerationResult([]) + expect(res.ui('contentMedia')).toBeModerationResult([]) + }) + + it('Custom labels can set the default setting', () => { + const modOpts: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { adultContentEnabled: true, - labels: { - porn: 'ignore', - }, + labels: {}, labelers: [ { - labeler: { - did: 'did:web:labeler.test', - displayName: 'Labeler', + did: 'did:web:labeler.test', + labels: {}, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'default-hide', + blurs: 'content', + severity: 'inform', + defaultSetting: 'hide', + locales: [], }, - labels: { - porn: 'hide', + 'did:web:labeler.test', + ), + interpretLabelValueDefinition( + { + identifier: 'default-warn', + blurs: 'content', + severity: 'inform', + defaultSetting: 'warn', + locales: [], }, - }, + 'did:web:labeler.test', + ), + interpretLabelValueDefinition( + { + identifier: 'default-ignore', + blurs: 'content', + severity: 'inform', + defaultSetting: 'ignore', + locales: [], + }, + 'did:web:labeler.test', + ), ], }, - ) - expect(res2.avatar).toBeModerationResult( - { blur: true }, - 'post avatar', - JSON.stringify(res2, null, 2), - true, + } + const res1 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-hide', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, ) - // porn (unspecified for labeler, hide for global) - const res3 = moderateProfile( - mock.profileViewBasic({ - handle: 'bob.test', - displayName: 'Bob', + expect(res1.ui('profileList')).toBeModerationResult([]) + expect(res1.ui('profileView')).toBeModerationResult([]) + expect(res1.ui('avatar')).toBeModerationResult([]) + expect(res1.ui('banner')).toBeModerationResult([]) + expect(res1.ui('displayName')).toBeModerationResult([]) + expect(res1.ui('contentList')).toBeModerationResult(['filter', 'blur']) + expect(res1.ui('contentView')).toBeModerationResult(['inform']) + expect(res1.ui('contentMedia')).toBeModerationResult([]) + + const res2 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), labels: [ { src: 'did:web:labeler.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', - val: 'porn', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-warn', cts: new Date().toISOString(), }, ], }), - { - userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: 'hide', + modOpts, + ) + + expect(res2.ui('profileList')).toBeModerationResult([]) + expect(res2.ui('profileView')).toBeModerationResult([]) + expect(res2.ui('avatar')).toBeModerationResult([]) + expect(res2.ui('banner')).toBeModerationResult([]) + expect(res2.ui('displayName')).toBeModerationResult([]) + expect(res2.ui('contentList')).toBeModerationResult(['blur']) + expect(res2.ui('contentView')).toBeModerationResult(['inform']) + expect(res2.ui('contentMedia')).toBeModerationResult([]) + + const res3 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-ignore', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, + ) + + expect(res3.ui('profileList')).toBeModerationResult([]) + expect(res3.ui('profileView')).toBeModerationResult([]) + expect(res3.ui('avatar')).toBeModerationResult([]) + expect(res3.ui('banner')).toBeModerationResult([]) + expect(res3.ui('displayName')).toBeModerationResult([]) + expect(res3.ui('contentList')).toBeModerationResult([]) + expect(res3.ui('contentView')).toBeModerationResult([]) + expect(res3.ui('contentMedia')).toBeModerationResult([]) + }) + + it('Custom labels can require adult content to be enabled', () => { + const modOpts: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: false, + labels: { adult: 'ignore' }, labelers: [ { - labeler: { - did: 'did:web:labeler.test', - displayName: 'Labeler', + did: 'did:web:labeler.test', + labels: { + adult: 'ignore', }, - labels: {}, }, ], + hiddenPosts: [], + mutedWords: [], }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'adult', + blurs: 'content', + severity: 'inform', + defaultSetting: 'hide', + adultOnly: true, + locales: [], + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'adult', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, ) - expect(res3.avatar).toBeModerationResult( - { blur: true }, - 'post avatar', - JSON.stringify(res3, 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([ + 'filter', + 'blur', + 'noOverride', + ]) + expect(res.ui('contentView')).toBeModerationResult(['blur', 'noOverride']) + expect(res.ui('contentMedia')).toBeModerationResult([]) }) - /* - TODO enable when 3P labeler support is added - it('Ignores labels from unknown labelers', () => { - const res1 = moderateProfile( - mock.profileViewBasic({ - handle: 'bob.test', - displayName: 'Bob', + it('Adult content disabled forces the preference to hide', () => { + const modOpts: ModerationOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: false, + labels: { porn: 'ignore' }, + labelers: [ + { + did: 'did:web:labeler.test', + labels: {}, + }, + ], + hiddenPosts: [], + mutedWords: [], + }, + labelDefs: {}, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), labels: [ { - src: 'did:web:rando.test', - uri: 'at://did:web:bob.test/app.bsky.actor.profile/self', + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', val: 'porn', cts: new Date().toISOString(), }, ], }), - { - userDid: 'did:web:alice.test', - adultContentEnabled: true, - labels: { - porn: '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(['filter']) + expect(res.ui('contentView')).toBeModerationResult([]) + expect(res.ui('contentMedia')).toBeModerationResult(['blur', 'noOverride']) + }) }) 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/rich-text-detection.test.ts b/packages/api/tests/rich-text-detection.test.ts index 9498005076c..084b5440a48 100644 --- a/packages/api/tests/rich-text-detection.test.ts +++ b/packages/api/tests/rich-text-detection.test.ts @@ -218,7 +218,7 @@ describe('detectFacets', () => { } }) - it('correctly detects tags inline', async () => { + describe('correctly detects tags inline', () => { const inputs: [ string, string[], @@ -234,31 +234,40 @@ describe('detectFacets', () => { ], ], ['#1', [], []], + ['#1a', ['1a'], [{ byteStart: 0, byteEnd: 3 }]], ['#tag', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['#tag body', ['tag'], [{ byteStart: 0, byteEnd: 4 }]], ['body #tag body', ['tag'], [{ byteStart: 5, byteEnd: 9 }]], ['body #1', [], []], + ['body #1a', ['1a'], [{ byteStart: 5, byteEnd: 8 }]], ['body #a1', ['a1'], [{ byteStart: 5, byteEnd: 8 }]], ['#', [], []], + ['#?', [], []], ['text #', [], []], ['text # text', [], []], [ - 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], - [{ byteStart: 5, byteEnd: 71 }], + 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + [{ byteStart: 5, byteEnd: 70 }], ], [ - 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', + 'body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', [], [], ], + [ + 'body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!', + ['thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'], + [{ byteStart: 5, byteEnd: 70 }], + ], [ 'its a #double#rainbow', ['double#rainbow'], [{ byteStart: 6, byteEnd: 21 }], ], ['##hashash', ['#hashash'], [{ byteStart: 0, byteEnd: 9 }]], + ['##', [], []], ['some #n0n3s@n5e!', ['n0n3s@n5e'], [{ byteStart: 5, byteEnd: 15 }]], [ 'works #with,punctuation', @@ -297,9 +306,47 @@ describe('detectFacets', () => { { byteStart: 17, byteEnd: 22 }, ], ], + ['this #️⃣tag should not be a tag', [], []], + [ + 'this ##️⃣tag should be a tag', + ['#️⃣tag'], + [ + { + byteStart: 5, + byteEnd: 16, + }, + ], + ], + [ + 'this #t\nag should be a tag', + ['t'], + [ + { + byteStart: 5, + byteEnd: 7, + }, + ], + ], + ['no match (\\u200B): #​', [], []], + ['no match (\\u200Ba): #​a', [], []], + ['match (a\\u200Bb): #a​b', ['a'], [{ byteStart: 18, byteEnd: 20 }]], + ['match (ab\\u200B): #ab​', ['ab'], [{ byteStart: 18, byteEnd: 21 }]], + ['no match (\\u20e2tag): #⃢tag', [], []], + ['no match (a\\u20e2b): #a⃢b', ['a'], [{ byteStart: 21, byteEnd: 23 }]], + [ + 'match full width number sign (tag): #tag', + ['tag'], + [{ byteStart: 36, byteEnd: 42 }], + ], + [ + 'match full width number sign (tag): ##️⃣tag', + ['#️⃣tag'], + [{ byteStart: 36, byteEnd: 49 }], + ], + ['no match 1?: #1?', [], []], ] - for (const [input, tags, indices] of inputs) { + it.each(inputs)('%s', async (input, tags, indices) => { const rt = new RichText({ text: input }) await rt.detectFacets(agent) @@ -318,7 +365,7 @@ describe('detectFacets', () => { expect(detectedTags).toEqual(tags) expect(detectedIndices).toEqual(indices) - } + }) }) }) 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..260753b0ab0 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, - ignoreCause = false, + 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,26 @@ 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 || {}, + labelers: [ + { did: 'did:plc:fake-labeler', - displayName: 'Fake Labeler', + labels: {}, }, - labels: this.suite.configurations[scenario.cfg].settings, - }, - ], + ], + mutedWords: [], + hiddenPosts: [], + }, } } } diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json index 02a84823b65..2a319212737 100644 --- a/packages/api/tsconfig.build.json +++ b/packages/api/tsconfig.build.json @@ -1,4 +1,9 @@ { - "extends": "./tsconfig.json", - "exclude": ["**/*.spec.ts", "**/*.test.ts"] + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noUnusedLocals": false + }, + "include": ["./src"] } diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 22ed93bd63f..1f3ab3c0ec4 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,13 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", // Your outDir, - "emitDeclarationOnly": true - }, - "include": ["./src"], + "include": [], "references": [ - { "path": "../xrpc/tsconfig.build.json" }, - { "path": "../lex-cli/tsconfig.build.json" } + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.tests.json" } ] } diff --git a/packages/api/tsconfig.tests.json b/packages/api/tsconfig.tests.json new file mode 100644 index 00000000000..bd21d2eeb5c --- /dev/null +++ b/packages/api/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig/tests.json", + "compilerOptions": { + "rootDir": ".", + "types": ["jest", "./jest.d.ts"], + "noEmit": true, + "noUnusedLocals": false + }, + "include": ["./tests"] +} diff --git a/packages/aws/CHANGELOG.md b/packages/aws/CHANGELOG.md index b804e0719e4..580969d75dc 100644 --- a/packages/aws/CHANGELOG.md +++ b/packages/aws/CHANGELOG.md @@ -1,5 +1,41 @@ # @atproto/aws +## 0.2.0 + +### Minor Changes + +- [#2169](https://github.com/bluesky-social/atproto/pull/2169) [`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Build system rework, stop bundling dependencies. + +### Patch Changes + +- Updated dependencies [[`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9)]: + - @atproto/common@0.4.0 + - @atproto/crypto@0.4.0 + - @atproto/repo@0.4.0 + +## 0.1.9 + +### Patch Changes + +- Updated dependencies []: + - @atproto/common@0.3.4 + - @atproto/repo@0.3.9 + - @atproto/crypto@0.3.0 + +## 0.1.8 + +### Patch Changes + +- Updated dependencies []: + - @atproto/repo@0.3.8 + +## 0.1.7 + +### Patch Changes + +- Updated dependencies [[`fcf8e3faf`](https://github.com/bluesky-social/atproto/commit/fcf8e3faf311559162c3aa0d9af36f84951914bc)]: + - @atproto/repo@0.3.7 + ## 0.1.6 ### Patch Changes diff --git a/packages/aws/build.js b/packages/aws/build.js deleted file mode 100644 index e880ae9930b..00000000000 --- a/packages/aws/build.js +++ /dev/null @@ -1,14 +0,0 @@ -const { nodeExternalsPlugin } = require('esbuild-node-externals') - -const buildShallow = - process.argv.includes('--shallow') || process.env.ATP_BUILD_SHALLOW === 'true' - -require('esbuild').build({ - logLevel: 'info', - entryPoints: ['src/index.ts'], - bundle: true, - sourcemap: true, - outdir: 'dist', - platform: 'node', - plugins: buildShallow ? [nodeExternalsPlugin()] : [], -}) diff --git a/packages/aws/package.json b/packages/aws/package.json index 949cfaa845e..4bf769c57e5 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/aws", - "version": "0.1.6", + "version": "0.2.0", "license": "MIT", "description": "Shared AWS cloud API helpers for atproto services", "keywords": [ @@ -13,15 +13,10 @@ "url": "https://github.com/bluesky-social/atproto", "directory": "packages/aws" }, - "main": "src/index.ts", - "publishConfig": { - "main": "dist/index.js", - "types": "dist/src/index.d.ts" - }, + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "node ./build.js", - "postbuild": "tsc --build tsconfig.build.json", - "update-main-to-dist": "node ../../update-main-to-dist.js packages/aws" + "build": "tsc --build tsconfig.build.json" }, "dependencies": { "@atproto/common": "workspace:^", diff --git a/packages/aws/tsconfig.build.json b/packages/aws/tsconfig.build.json index 02a84823b65..436d8ecb628 100644 --- a/packages/aws/tsconfig.build.json +++ b/packages/aws/tsconfig.build.json @@ -1,4 +1,8 @@ { - "extends": "./tsconfig.json", - "exclude": ["**/*.spec.ts", "**/*.test.ts"] + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/packages/aws/tsconfig.json b/packages/aws/tsconfig.json index fee83b7f23b..e84b8178b47 100644 --- a/packages/aws/tsconfig.json +++ b/packages/aws/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", // Your outDir, - "emitDeclarationOnly": true - }, - "include": ["./src", "__tests__/**/**.ts"] + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] } diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 8ce5294eb24..0877146fcc9 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,135 @@ # @atproto/bsky +## 0.0.43 + +### Patch Changes + +- Updated dependencies [[`abc6f82da`](https://github.com/bluesky-social/atproto/commit/abc6f82da38abef2b1bbe8d9e41a0534a5418c9e)]: + - @atproto/api@0.12.2 + +## 0.0.42 + +### Patch Changes + +- [#2342](https://github.com/bluesky-social/atproto/pull/2342) [`eb7668c07`](https://github.com/bluesky-social/atproto/commit/eb7668c07d44f4b42ea2cc28143c64f4ba3312f5) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Adds the `associated` property to `profile` and `profile-basic` views, bringing them in line with `profile-detailed` views. + +- Updated dependencies [[`eb7668c07`](https://github.com/bluesky-social/atproto/commit/eb7668c07d44f4b42ea2cc28143c64f4ba3312f5)]: + - @atproto/api@0.12.1 + +## 0.0.41 + +### Patch Changes + +- [#2169](https://github.com/bluesky-social/atproto/pull/2169) [`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Build system rework, stop bundling dependencies. + +- Updated dependencies [[`f689bd51a`](https://github.com/bluesky-social/atproto/commit/f689bd51a2f4e02d4eca40eb2568a1fcb95494e9), [`36f2e966c`](https://github.com/bluesky-social/atproto/commit/36f2e966cba6cc90ba4320520da5c7381cfb8086)]: + - @atproto/xrpc-server@0.5.0 + - @atproto/identity@0.4.0 + - @atproto/lexicon@0.4.0 + - @atproto/common@0.4.0 + - @atproto/crypto@0.4.0 + - @atproto/syntax@0.3.0 + - @atproto/repo@0.4.0 + - @atproto/api@0.12.0 + +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`7dd9941b7`](https://github.com/bluesky-social/atproto/commit/7dd9941b73dbbd82601740e021cc87d765af60ca)]: + - @atproto/api@0.11.2 + +## 0.0.39 + +### Patch Changes + +- Updated dependencies [[`219480764`](https://github.com/bluesky-social/atproto/commit/2194807644cbdb0021e867437693300c1b0e55f5)]: + - @atproto/api@0.11.1 + +## 0.0.38 + +### Patch Changes + +- Updated dependencies [[`4eaadc0ac`](https://github.com/bluesky-social/atproto/commit/4eaadc0acb6b73b9745dd7a2b929d02e58083ab0), [`4eaadc0ac`](https://github.com/bluesky-social/atproto/commit/4eaadc0acb6b73b9745dd7a2b929d02e58083ab0)]: + - @atproto/identity@0.3.3 + - @atproto/api@0.11.0 + - @atproto/common@0.3.4 + - @atproto/lexicon@0.3.3 + - @atproto/repo@0.3.9 + - @atproto/syntax@0.2.1 + - @atproto/crypto@0.3.0 + - @atproto/xrpc-server@0.4.4 + +## 0.0.37 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + +- Updated dependencies [[`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655)]: + - @atproto/api@0.10.5 + +## 0.0.36 + +### Patch Changes + +- Updated dependencies [[`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f), [`6ec885992`](https://github.com/bluesky-social/atproto/commit/6ec8859929a16f9725319cc398b716acf913b01f)]: + - @atproto/api@0.10.4 + +## 0.0.35 + +### Patch Changes + +- Updated dependencies [[`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed), [`2a0ceb818`](https://github.com/bluesky-social/atproto/commit/2a0ceb8180faa17de8061d4fa6c361b57a2005ed)]: + - @atproto/api@0.10.3 + +## 0.0.34 + +### Patch Changes + +- Updated dependencies [[`0c815b964`](https://github.com/bluesky-social/atproto/commit/0c815b964c030aa0f277c40bf9786f130dc320f4), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410), [`43531905c`](https://github.com/bluesky-social/atproto/commit/43531905ce1aec6d36d9be5943782811ecca6e6d), [`61b3d2525`](https://github.com/bluesky-social/atproto/commit/61b3d25253353db2da1336004f94e7dc5adb0410)]: + - @atproto/syntax@0.2.0 + - @atproto/api@0.10.2 + - @atproto/lexicon@0.3.2 + - @atproto/repo@0.3.8 + - @atproto/xrpc-server@0.4.3 + +## 0.0.33 + +### Patch Changes + +- Updated dependencies [[`514aab92d`](https://github.com/bluesky-social/atproto/commit/514aab92d26acd43859285f46318e386846522b1)]: + - @atproto/api@0.10.1 + +## 0.0.32 + +### Patch Changes + +- Updated dependencies [[`b60719480`](https://github.com/bluesky-social/atproto/commit/b60719480f5f00bffd074a40e8ddc03aa93d137d), [`4c511b3d9`](https://github.com/bluesky-social/atproto/commit/4c511b3d9de41ffeae3fc11db941e7df04f4468a)]: + - @atproto/api@0.10.0 + +## 0.0.31 + +### Patch Changes + +- Updated dependencies [[`f79cc6339`](https://github.com/bluesky-social/atproto/commit/f79cc63390ae9dbd47a4ff5d694eec25b78b788e)]: + - @atproto/api@0.9.8 + +## 0.0.30 + +### Patch Changes + +- Updated dependencies [[`fcf8e3faf`](https://github.com/bluesky-social/atproto/commit/fcf8e3faf311559162c3aa0d9af36f84951914bc), [`8c94979f7`](https://github.com/bluesky-social/atproto/commit/8c94979f73fc5057449e24e66ef2e09b0e17e55b)]: + - @atproto/repo@0.3.7 + - @atproto/api@0.9.7 + +## 0.0.29 + +### Patch Changes + +- Updated dependencies [[`e4ec7af03`](https://github.com/bluesky-social/atproto/commit/e4ec7af03608949fc3b00a845f547a77599b5ad0)]: + - @atproto/api@0.9.6 + ## 0.0.28 ### Patch Changes diff --git a/packages/bsky/babel.config.js b/packages/bsky/babel.config.js deleted file mode 100644 index ee58f35df11..00000000000 --- a/packages/bsky/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [['@babel/preset-env']], -} diff --git a/packages/bsky/bin/migration-create.ts b/packages/bsky/bin/migration-create.ts index b51c536c4f2..27edc5e0979 100644 --- a/packages/bsky/bin/migration-create.ts +++ b/packages/bsky/bin/migration-create.ts @@ -14,7 +14,15 @@ export async function main() { ) } const filename = `${prefix}-${name}` - const dir = path.join(__dirname, '..', 'src', 'db', 'migrations') + const dir = path.join( + __dirname, + '..', + 'src', + 'data-plane', + 'server', + 'db', + 'migrations', + ) await fs.writeFile(path.join(dir, `${filename}.ts`), template, { flag: 'wx' }) await fs.writeFile( diff --git a/packages/bsky/buf.gen.yaml b/packages/bsky/buf.gen.yaml index a81e4248719..a7ecaa0a540 100644 --- a/packages/bsky/buf.gen.yaml +++ b/packages/bsky/buf.gen.yaml @@ -3,10 +3,10 @@ plugins: - plugin: es opt: - target=ts - - import_extension=.ts + - import_extension= out: src/proto - plugin: connect-es opt: - target=ts - - import_extension=.ts + - import_extension= out: src/proto diff --git a/packages/bsky/build.js b/packages/bsky/build.js deleted file mode 100644 index 3822d9bc98f..00000000000 --- a/packages/bsky/build.js +++ /dev/null @@ -1,19 +0,0 @@ -const { nodeExternalsPlugin } = require('esbuild-node-externals') - -const buildShallow = - process.argv.includes('--shallow') || process.env.ATP_BUILD_SHALLOW === 'true' - -require('esbuild').build({ - logLevel: 'info', - entryPoints: ['src/index.ts', 'src/db/index.ts'], - bundle: true, - sourcemap: true, - outdir: 'dist', - platform: 'node', - external: [ - // Referenced in pg driver, but optional and we don't use it - 'pg-native', - 'sharp', - ], - plugins: buildShallow ? [nodeExternalsPlugin()] : [], -}) diff --git a/packages/bsky/jest.config.js b/packages/bsky/jest.config.js index 14720ce82eb..ee315e79d22 100644 --- a/packages/bsky/jest.config.js +++ b/packages/bsky/jest.config.js @@ -1,6 +1,8 @@ -const base = require('../../jest.config.base.js') - +/** @type {import('jest').Config} */ module.exports = { - ...base, displayName: 'Bsky App View', + transform: { '^.+\\.(t|j)s$': '@swc/jest' }, + transformIgnorePatterns: [`/node_modules/(?!get-port)`], + testTimeout: 60000, + setupFiles: ['/../../jest.setup.ts'], } diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 7187e17f1bd..3be82bfabf4 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.28", + "version": "0.0.43", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ @@ -13,17 +13,12 @@ "url": "https://github.com/bluesky-social/atproto", "directory": "packages/bsky" }, - "main": "src/index.ts", - "publishConfig": { - "main": "dist/index.js", - "types": "dist/index.d.ts" - }, + "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": "dist/bin.js", "scripts": { "codegen": "lex gen-server ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*", - "build": "node ./build.js", - "postbuild": "tsc --build tsconfig.build.json", - "update-main-to-dist": "node ../../update-main-to-dist.js packages/bsky", + "build": "tsc --build tsconfig.build.json", "start": "node --enable-source-maps dist/bin.js", "test": "../dev-infra/with-test-redis-and-db.sh jest", "test:log": "tail -50 test.log | pino-pretty", @@ -42,32 +37,29 @@ "@atproto/xrpc-server": "workspace:^", "@bufbuild/protobuf": "^1.5.0", "@connectrpc/connect": "^1.1.4", + "@connectrpc/connect-express": "^1.1.4", "@connectrpc/connect-node": "^1.1.4", "@did-plc/lib": "^0.0.1", - "@isaacs/ttlcache": "^1.4.1", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.2", - "express-async-errors": "^3.1.1", - "form-data": "^4.0.0", "http-errors": "^2.0.0", "http-terminator": "^3.2.0", "ioredis": "^5.3.2", "jose": "^5.0.1", "kysely": "^0.22.0", "multiformats": "^9.9.0", - "murmurhash": "^2.0.1", "p-queue": "^6.6.2", "pg": "^8.10.0", "pino": "^8.15.0", "pino-http": "^8.2.1", "sharp": "^0.32.6", + "structured-headers": "^1.0.1", "typed-emitter": "^2.1.0", "uint8arrays": "3.0.0" }, "devDependencies": { "@atproto/api": "workspace:^", - "@atproto/dev-env": "workspace:^", "@atproto/lex-cli": "workspace:^", "@atproto/pds": "workspace:^", "@atproto/xrpc": "workspace:^", @@ -80,6 +72,8 @@ "@types/express-serve-static-core": "^4.17.36", "@types/pg": "^8.6.6", "@types/qs": "^6.9.7", - "axios": "^0.27.2" + "axios": "^0.27.2", + "jest": "^28.1.2", + "ts-node": "^10.8.2" } } diff --git a/packages/bsky/proto/bsky.proto b/packages/bsky/proto/bsky.proto new file mode 100644 index 00000000000..3820de7d564 --- /dev/null +++ b/packages/bsky/proto/bsky.proto @@ -0,0 +1,1176 @@ +syntax = "proto3"; + +package bsky; +option go_package = "./;bsky"; + +import "google/protobuf/timestamp.proto"; + +// +// Read Path +// + +message Record { + bytes record = 1; + string cid = 2; + google.protobuf.Timestamp indexed_at = 4; + bool taken_down = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp sorted_at = 7; + string takedown_ref = 8; +} + +message GetBlockRecordsRequest { + repeated string uris = 1; +} + +message GetBlockRecordsResponse { + repeated Record records = 1; +} + +message GetFeedGeneratorRecordsRequest { + repeated string uris = 1; +} + +message GetFeedGeneratorRecordsResponse { + repeated Record records = 1; +} + +message GetFollowRecordsRequest { + repeated string uris = 1; +} + +message GetFollowRecordsResponse { + repeated Record records = 1; +} + +message GetLikeRecordsRequest { + repeated string uris = 1; +} + +message GetLikeRecordsResponse { + repeated Record records = 1; +} + +message GetListBlockRecordsRequest { + repeated string uris = 1; +} + +message GetListBlockRecordsResponse { + repeated Record records = 1; +} + +message GetListItemRecordsRequest { + repeated string uris = 1; +} + +message GetListItemRecordsResponse { + repeated Record records = 1; +} + +message GetListRecordsRequest { + repeated string uris = 1; +} + +message GetListRecordsResponse { + repeated Record records = 1; +} + +message PostRecordMeta { + bool violates_thread_gate = 1; + bool has_media = 2; + bool is_reply = 3; +} + +message GetPostRecordsRequest { + repeated string uris = 1; +} + +message GetPostRecordsResponse { + repeated Record records = 1; + repeated PostRecordMeta meta = 2; +} + +message GetProfileRecordsRequest { + repeated string uris = 1; +} + +message GetProfileRecordsResponse { + repeated Record records = 1; +} + +message GetRepostRecordsRequest { + repeated string uris = 1; +} + +message GetRepostRecordsResponse { + repeated Record records = 1; +} + +message GetThreadGateRecordsRequest { + repeated string uris = 1; +} + +message GetThreadGateRecordsResponse { + repeated Record records = 1; +} + +message GetLabelerRecordsRequest { + repeated string uris = 1; +} + +message GetLabelerRecordsResponse { + repeated Record records = 1; +} + + +// +// Follows +// + +// - Return follow uris where user A follows users B, C, D, … +// - E.g. for viewer state on `getProfiles` +message GetActorFollowsActorsRequest { + string actor_did = 1; + repeated string target_dids = 2; +} + +message GetActorFollowsActorsResponse { + repeated string uris = 1; +} + +// - Return follow uris of users who follows user A +// - For `getFollowers` list +message GetFollowersRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message FollowInfo { + string uri = 1; + string actor_did = 2; + string subject_did = 3; +} + +message GetFollowersResponse { + repeated FollowInfo followers = 1; + string cursor = 2; +} + +// - Return follow uris of users A follows +// - For `getFollows` list +message GetFollowsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetFollowsResponse { + repeated FollowInfo follows = 1; + string cursor = 2; +} + +// +// Likes +// + +// - return like uris where subject uri is subject A +// - `getLikes` list for a post +message GetLikesBySubjectRequest { + RecordRef subject = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetLikesBySubjectResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - return like uris for user A on subject B, C, D... +// - viewer state on posts +message GetLikesByActorAndSubjectsRequest { + string actor_did = 1; + repeated RecordRef refs = 2; +} + +message GetLikesByActorAndSubjectsResponse { + repeated string uris = 1; +} + +// - return recent like uris for user A +// - `getActorLikes` list for a user +message GetActorLikesRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message LikeInfo { + string uri = 1; + string subject = 2; +} + +message GetActorLikesResponse { + repeated LikeInfo likes = 1; + string cursor = 2; +} + +// +// Interactions +// +message GetInteractionCountsRequest { + repeated RecordRef refs = 1; +} + +message GetInteractionCountsResponse { + repeated int32 likes = 1; + repeated int32 reposts = 2; + repeated int32 replies = 3; +} + +message GetCountsForUsersRequest { + repeated string dids = 1; +} + +message GetCountsForUsersResponse { + repeated int32 posts = 1; + repeated int32 reposts = 2; + repeated int32 following = 3; + repeated int32 followers = 4; + repeated int32 lists = 5; + repeated int32 feeds = 6; +} + +// +// Reposts +// + +// - return repost uris where subject uri is subject A +// - `getReposts` list for a post +message GetRepostsBySubjectRequest { + RecordRef subject = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetRepostsBySubjectResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - return repost uris for user A on subject B, C, D... +// - viewer state on posts +message GetRepostsByActorAndSubjectsRequest { + string actor_did = 1; + repeated RecordRef refs = 2; +} + +message RecordRef { + string uri = 1; + string cid = 2; +} + +message GetRepostsByActorAndSubjectsResponse { + repeated string uris = 1; +} + +// - return recent repost uris for user A +// - `getActorReposts` list for a user +message GetActorRepostsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorRepostsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// +// Profile +// + +// - return actor information for dids A, B, C… +// - profile hydration +// - should this include handles? apply repo takedown? +message GetActorsRequest { + repeated string dids = 1; +} + +message ActorInfo { + bool exists = 1; + string handle = 2; + Record profile = 3; + bool taken_down = 4; + string takedown_ref = 5; + google.protobuf.Timestamp tombstoned_at = 6; + bool labeler = 7; +} + +message GetActorsResponse { + repeated ActorInfo actors = 1; +} + +// - return did for handle A +// - `resolveHandle` +// - answering queries where the query param is a handle +message GetDidsByHandlesRequest { + repeated string handles = 1; +} + +message GetDidsByHandlesResponse { + repeated string dids = 1; +} + +// +// Relationships +// + +// - return relationships between user A and users B, C, D... +// - profile hydration +// - block application +message GetRelationshipsRequest { + string actor_did = 1; + repeated string target_dids = 2; +} + +message Relationships { + bool muted = 1; + string muted_by_list = 2; + string blocked_by = 3; + string blocking = 4; + string blocked_by_list = 5; + string blocking_by_list = 6; + string following = 7; + string followed_by = 8; +} + +message GetRelationshipsResponse { + repeated Relationships relationships = 1; +} + +// - return whether a block (bidrectionally and either direct or through a list) exists between two dids +// - enforcing 3rd party block violations +message RelationshipPair { + string a = 1; + string b = 2; +} + +message GetBlockExistenceRequest { + repeated RelationshipPair pairs = 1; +} + +message GetBlockExistenceResponse { + repeated bool exists = 1; +} + + +// +// Lists +// + +message ListItemInfo { + string uri = 1; + string did = 2; +} + +// - Return dids of users in list A +// - E.g. to view items in one of your mute lists +message GetListMembersRequest { + string list_uri = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetListMembersResponse { + repeated ListItemInfo listitems = 1; + string cursor = 2; +} + +// - Return list uris where user A in list B, C, D… +// - Used in thread reply gates +message GetListMembershipRequest { + string actor_did = 1; + repeated string list_uris = 2; +} + +message GetListMembershipResponse { + repeated string listitem_uris = 1; +} + +// - Return number of items in list A +// - For aggregate +message GetListCountRequest { + string list_uri = 1; +} + +message GetListCountResponse { + int32 count = 1; +} + + +// - return list of uris of lists created by A +// - `getLists` +message GetActorListsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorListsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Mutes +// + +// - return boolean if user A has muted user B +// - hydrating mute state onto profiles +message GetActorMutesActorRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetActorMutesActorResponse { + bool muted = 1; +} + +// - return list of user dids of users who A mutes +// - `getMutes` +message GetMutesRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetMutesResponse { + repeated string dids = 1; + string cursor = 2; +} + +// +// Mutelists +// + +// - return list uri of *any* list through which user A has muted user B +// - hydrating mute state onto profiles +// - note: we only need *one* uri even if a user is muted by multiple lists +message GetActorMutesActorViaListRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetActorMutesActorViaListResponse { + string list_uri = 1; +} + +// - return boolean if actor A has subscribed to mutelist B +// - list view hydration +message GetMutelistSubscriptionRequest { + string actor_did = 1; + string list_uri = 2; +} + +message GetMutelistSubscriptionResponse { + bool subscribed = 1; +} + +// - return list of list uris of mutelists that A subscribes to +// - `getListMutes` +message GetMutelistSubscriptionsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetMutelistSubscriptionsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Blocks +// + +// - Return block uri if there is a block between users A & B (bidirectional) +// - hydrating (& actioning) block state on profiles +// - handling 3rd party blocks +message GetBidirectionalBlockRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetBidirectionalBlockResponse { + string block_uri = 1; +} + +// - Return list of block uris and user dids of users who A blocks +// - `getBlocks` +message GetBlocksRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetBlocksResponse { + repeated string block_uris = 1; + string cursor = 2; +} + +// +// Blocklists +// + +// - Return list uri of ***any*** list through which users A & B have a block (bidirectional) +// - hydrating (& actioning) block state on profiles +// - handling 3rd party blocks +message GetBidirectionalBlockViaListRequest { + string actor_did = 1; + string target_did = 2; +} + +message GetBidirectionalBlockViaListResponse { + string list_uri = 1; +} + +// - return boolean if user A has subscribed to blocklist B +// - list view hydration +message GetBlocklistSubscriptionRequest { + string actor_did = 1; + string list_uri = 2; +} + +message GetBlocklistSubscriptionResponse { + string listblock_uri = 1; +} + +// - return list of list uris of Blockslists that A subscribes to +// - `getListBlocks` +message GetBlocklistSubscriptionsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetBlocklistSubscriptionsResponse { + repeated string list_uris = 1; + string cursor = 2; +} + +// +// Notifications +// + +// - list recent notifications for a user +// - notifications should include a uri for the record that caused the notif & a “reason” for the notification (reply, like, quotepost, etc) +// - this should include both read & unread notifs +message GetNotificationsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message Notification { + string recipient_did = 1; + string uri = 2; + string reason = 3; + string reason_subject = 4; + google.protobuf.Timestamp timestamp = 5; +} + +message GetNotificationsResponse { + repeated Notification notifications = 1; + string cursor = 2; +} + +// - update a user’s “last seen time” +// - `updateSeen` +message UpdateNotificationSeenRequest { + string actor_did = 1; + google.protobuf.Timestamp timestamp = 2; +} + +message UpdateNotificationSeenResponse {} + +// - get a user’s “last seen time” +// - hydrating read state onto notifications +message GetNotificationSeenRequest { + string actor_did = 1; +} + +message GetNotificationSeenResponse { + google.protobuf.Timestamp timestamp = 1; +} + +// - get a count of all unread notifications (notifications after `updateSeen`) +// - `getUnreadCount` +message GetUnreadNotificationCountRequest { + string actor_did = 1; +} + +message GetUnreadNotificationCountResponse { + int32 count = 1; +} + +// +// FeedGenerators +// + +// - Return uris of feed generator records created by user A +// - `getActorFeeds` +message GetActorFeedsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetActorFeedsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// - Returns a list of suggested feed generator uris for an actor, paginated +// - `getSuggestedFeeds` +// - This is currently just hardcoded in the Appview DB +message GetSuggestedFeedsRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; +} + +message GetSuggestedFeedsResponse { + repeated string uris = 1; + string cursor = 2; +} + +message SearchFeedGeneratorsRequest { + string query = 1; + int32 limit = 2; +} + +message SearchFeedGeneratorsResponse { + repeated string uris = 1; +} + +// - Returns feed generator validity and online status with uris A, B, C… +// - Not currently being used, but could be worhthwhile. +message GetFeedGeneratorStatusRequest { + repeated string uris = 1; +} + +message GetFeedGeneratorStatusResponse { + repeated string status = 1; +} + +// +// Feeds +// + +enum FeedType { + FEED_TYPE_UNSPECIFIED = 0; + FEED_TYPE_POSTS_AND_AUTHOR_THREADS = 1; + FEED_TYPE_POSTS_NO_REPLIES = 2; + FEED_TYPE_POSTS_WITH_MEDIA = 3; +} + +// - Returns recent posts authored by a given DID, paginated +// - `getAuthorFeed` +// - Optionally: filter by if a post is/isn’t a reply and if a post has a media object in it +message GetAuthorFeedRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; + FeedType feed_type = 4; +} + +message AuthorFeedItem { + string uri = 1; + string cid = 2; + string repost = 3; + string repost_cid = 4; + bool posts_and_author_threads = 5; + bool posts_no_replies = 6; + bool posts_with_media = 7; + bool is_reply = 8; + bool is_repost = 9; + bool is_quote_post = 10; +} + +message GetAuthorFeedResponse { + repeated AuthorFeedItem items = 1; + string cursor = 2; +} + +// - Returns recent posts authored by users followed by a given DID, paginated +// - `getTimeline` +message GetTimelineRequest { + string actor_did = 1; + int32 limit = 2; + string cursor = 3; + bool exclude_replies = 4; + bool exclude_reposts = 5; + bool exclude_quotes = 6; +} + +message GetTimelineResponse { + repeated TimelineFeedItem items = 1; + string cursor = 2; +} + +message TimelineFeedItem { + string uri = 1; + string cid = 2; + string repost = 3; + string repost_cid = 4; + bool is_reply = 5; + bool is_repost = 6; + bool is_quote_post = 7; +} + +// - Return recent post uris from users in list A +// - `getListFeed` +// - (This is essentially the same as `getTimeline` but instead of follows of a did, it is list items of a list) +message GetListFeedRequest { + string list_uri = 1; + int32 limit = 2; + string cursor = 3; + bool exclude_replies = 4; + bool exclude_reposts = 5; + bool exclude_quotes = 6; +} + +message GetListFeedResponse { + repeated TimelineFeedItem items = 1; + string cursor = 2; +} + +// +// Threads +// + +// Return posts uris of any replies N levels above or M levels below post A +message GetThreadRequest { + string post_uri = 1; + int32 above = 2; + int32 below = 3; +} + +message GetThreadResponse { + repeated string uris = 1; +} + +// +// Search +// + +// - Return DIDs of actors matching term, paginated +// - `searchActors` skeleton +message SearchActorsRequest { + string term = 1; + int32 limit = 2; + string cursor = 3; +} + +message SearchActorsResponse { + repeated string dids = 1; + string cursor = 2; +} + +// - Return uris of posts matching term, paginated +// - `searchPosts` skeleton +message SearchPostsRequest { + string term = 1; + int32 limit = 2; + string cursor = 3; +} + +message SearchPostsResponse { + repeated string uris = 1; + string cursor = 2; +} + +// +// Suggestions +// + +// - Return DIDs of suggested follows for a user, excluding anyone they already follow +// - `getSuggestions`, `getSuggestedFollowsByActor` +message GetFollowSuggestionsRequest { + string actor_did = 1; + string relative_to_did = 2; + int32 limit = 3; + string cursor = 4; +} + +message GetFollowSuggestionsResponse { + repeated string dids = 1; + string cursor = 2; +} + +message SuggestedEntity { + string tag = 1; + string subject = 2; + string subject_type = 3; + int64 priority = 4; +} + +message GetSuggestedEntitiesRequest { + int32 limit = 1; + string cursor = 2; +} + +message GetSuggestedEntitiesResponse { + repeated SuggestedEntity entities = 1; + string cursor = 2; +} + +// +// Posts +// + +// - Return post reply count with uris A, B, C… +// - All feed hydration +message GetPostReplyCountsRequest { + repeated RecordRef refs = 1; +} + +message GetPostReplyCountsResponse { + repeated int32 counts = 1; +} + +// +// Labels +// + +// - Get all labels on a subjects A, B, C (uri or did) issued by dids D, E, F… +// - label hydration on nearly every view +message GetLabelsRequest { + repeated string subjects = 1; + repeated string issuers = 2; +} + +message GetLabelsResponse { + repeated bytes labels = 1; +} + +// +// Sync +// + +// - Latest repo rev of user w/ DID +// - Read-after-write header in`getProfile`, `getProfiles`, `getActorLikes`, `getAuthorFeed`, `getListFeed`, `getPostThread`, `getTimeline`. Could it be view dependent? +message GetLatestRevRequest { + string actor_did = 1; +} + +message GetLatestRevResponse { + string rev = 1; +} + + +message GetIdentityByDidRequest { + string did = 1; +} +message GetIdentityByDidResponse { + string did = 1; + string handle = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + +message GetIdentityByHandleRequest { + string handle = 1; +} +message GetIdentityByHandleResponse { + string handle = 1; + string did = 2; + bytes keys = 3; + bytes services = 4; + google.protobuf.Timestamp updated = 5; +} + + + +// +// Moderation +// + +message GetBlobTakedownRequest { + string did = 1; + string cid = 2; +} + +message GetBlobTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + + + +message GetActorTakedownRequest { + string did = 1; +} + +message GetActorTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + +message GetRecordTakedownRequest { + string record_uri = 1; +} + +message GetRecordTakedownResponse { + bool taken_down = 1; + string takedown_ref = 2; +} + + +// Ping +message PingRequest {} +message PingResponse {} + + + + +service Service { + // + // Read Path + // + + // Records + rpc GetBlockRecords(GetBlockRecordsRequest) returns (GetBlockRecordsResponse); + rpc GetFeedGeneratorRecords(GetFeedGeneratorRecordsRequest) returns (GetFeedGeneratorRecordsResponse); + rpc GetFollowRecords(GetFollowRecordsRequest) returns (GetFollowRecordsResponse); + rpc GetLikeRecords(GetLikeRecordsRequest) returns (GetLikeRecordsResponse); + rpc GetListBlockRecords(GetListBlockRecordsRequest) returns (GetListBlockRecordsResponse); + rpc GetListItemRecords(GetListItemRecordsRequest) returns (GetListItemRecordsResponse); + rpc GetListRecords(GetListRecordsRequest) returns (GetListRecordsResponse); + rpc GetPostRecords(GetPostRecordsRequest) returns (GetPostRecordsResponse); + 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); + rpc GetFollowers(GetFollowersRequest) returns (GetFollowersResponse); + rpc GetFollows(GetFollowsRequest) returns (GetFollowsResponse); + + // Likes + rpc GetLikesBySubject(GetLikesBySubjectRequest) returns (GetLikesBySubjectResponse); + rpc GetLikesByActorAndSubjects(GetLikesByActorAndSubjectsRequest) returns (GetLikesByActorAndSubjectsResponse); + rpc GetActorLikes(GetActorLikesRequest) returns (GetActorLikesResponse); + + // Reposts + rpc GetRepostsBySubject(GetRepostsBySubjectRequest) returns (GetRepostsBySubjectResponse); + rpc GetRepostsByActorAndSubjects(GetRepostsByActorAndSubjectsRequest) returns (GetRepostsByActorAndSubjectsResponse); + rpc GetActorReposts(GetActorRepostsRequest) returns (GetActorRepostsResponse); + + // Interaction Counts + rpc GetInteractionCounts(GetInteractionCountsRequest) returns (GetInteractionCountsResponse); + rpc GetCountsForUsers(GetCountsForUsersRequest) returns (GetCountsForUsersResponse); + + // Profile + rpc GetActors(GetActorsRequest) returns (GetActorsResponse); + rpc GetDidsByHandles(GetDidsByHandlesRequest) returns (GetDidsByHandlesResponse); + + // Relationships + rpc GetRelationships(GetRelationshipsRequest) returns (GetRelationshipsResponse); + rpc GetBlockExistence(GetBlockExistenceRequest) returns (GetBlockExistenceResponse); + + // Lists + rpc GetActorLists(GetActorListsRequest) returns (GetActorListsResponse); + rpc GetListMembers(GetListMembersRequest) returns (GetListMembersResponse); + rpc GetListMembership(GetListMembershipRequest) returns (GetListMembershipResponse); + rpc GetListCount(GetListCountRequest) returns (GetListCountResponse); + + // Mutes + rpc GetActorMutesActor(GetActorMutesActorRequest) returns (GetActorMutesActorResponse); + rpc GetMutes(GetMutesRequest) returns (GetMutesResponse); + + // Mutelists + rpc GetActorMutesActorViaList(GetActorMutesActorViaListRequest) returns (GetActorMutesActorViaListResponse); + rpc GetMutelistSubscription(GetMutelistSubscriptionRequest) returns (GetMutelistSubscriptionResponse); + rpc GetMutelistSubscriptions(GetMutelistSubscriptionsRequest) returns (GetMutelistSubscriptionsResponse); + + // Blocks + rpc GetBidirectionalBlock(GetBidirectionalBlockRequest) returns (GetBidirectionalBlockResponse); + rpc GetBlocks(GetBlocksRequest) returns (GetBlocksResponse); + + // Blocklists + rpc GetBidirectionalBlockViaList(GetBidirectionalBlockViaListRequest) returns (GetBidirectionalBlockViaListResponse); + rpc GetBlocklistSubscription(GetBlocklistSubscriptionRequest) returns (GetBlocklistSubscriptionResponse); + rpc GetBlocklistSubscriptions(GetBlocklistSubscriptionsRequest) returns (GetBlocklistSubscriptionsResponse); + + // Notifications + rpc GetNotifications(GetNotificationsRequest) returns (GetNotificationsResponse); + rpc GetNotificationSeen(GetNotificationSeenRequest) returns (GetNotificationSeenResponse); + rpc GetUnreadNotificationCount(GetUnreadNotificationCountRequest) returns (GetUnreadNotificationCountResponse); + rpc UpdateNotificationSeen(UpdateNotificationSeenRequest) returns (UpdateNotificationSeenResponse); + + // FeedGenerators + rpc GetActorFeeds(GetActorFeedsRequest) returns (GetActorFeedsResponse); + rpc GetSuggestedFeeds(GetSuggestedFeedsRequest) returns (GetSuggestedFeedsResponse); + rpc GetFeedGeneratorStatus(GetFeedGeneratorStatusRequest) returns (GetFeedGeneratorStatusResponse); + rpc SearchFeedGenerators(SearchFeedGeneratorsRequest) returns (SearchFeedGeneratorsResponse); + + // Feeds + rpc GetAuthorFeed(GetAuthorFeedRequest) returns (GetAuthorFeedResponse); + rpc GetTimeline(GetTimelineRequest) returns (GetTimelineResponse); + rpc GetListFeed(GetListFeedRequest) returns (GetListFeedResponse); + + // Threads + rpc GetThread(GetThreadRequest) returns (GetThreadResponse); + + // Search + rpc SearchActors(SearchActorsRequest) returns (SearchActorsResponse); + rpc SearchPosts(SearchPostsRequest) returns (SearchPostsResponse); + + // Suggestions + rpc GetFollowSuggestions(GetFollowSuggestionsRequest) returns (GetFollowSuggestionsResponse); + rpc GetSuggestedEntities(GetSuggestedEntitiesRequest) returns (GetSuggestedEntitiesResponse); + + // Posts + rpc GetPostReplyCounts(GetPostReplyCountsRequest) returns (GetPostReplyCountsResponse); + + // Labels + rpc GetLabels(GetLabelsRequest) returns (GetLabelsResponse); + + // Sync + rpc GetLatestRev(GetLatestRevRequest) returns (GetLatestRevResponse); + + // Moderation + rpc GetBlobTakedown(GetBlobTakedownRequest) returns (GetBlobTakedownResponse); + rpc GetRecordTakedown(GetRecordTakedownRequest) returns (GetRecordTakedownResponse); + rpc GetActorTakedown(GetActorTakedownRequest) returns (GetActorTakedownResponse); + + // Identity + rpc GetIdentityByDid(GetIdentityByDidRequest) returns (GetIdentityByDidResponse); + rpc GetIdentityByHandle(GetIdentityByHandleRequest) returns (GetIdentityByHandleResponse); + + // Ping + rpc Ping(PingRequest) returns (PingResponse); + + + + + // + // Write Path + // + + // Moderation + rpc TakedownBlob(TakedownBlobRequest) returns (TakedownBlobResponse); + rpc TakedownRecord(TakedownRecordRequest) returns (TakedownRecordResponse); + rpc TakedownActor(TakedownActorRequest) returns (TakedownActorResponse); + + rpc UntakedownBlob(UntakedownBlobRequest) returns (UntakedownBlobResponse); + rpc UntakedownRecord(UntakedownRecordRequest) returns (UntakedownRecordResponse); + rpc UntakedownActor(UntakedownActorRequest) returns (UntakedownActorResponse); + + // Ingestion + rpc CreateActorMute(CreateActorMuteRequest) returns (CreateActorMuteResponse); + rpc DeleteActorMute(DeleteActorMuteRequest) returns (DeleteActorMuteResponse); + rpc ClearActorMutes(ClearActorMutesRequest) returns (ClearActorMutesResponse); + + rpc CreateActorMutelistSubscription(CreateActorMutelistSubscriptionRequest) returns (CreateActorMutelistSubscriptionResponse); + rpc DeleteActorMutelistSubscription(DeleteActorMutelistSubscriptionRequest) returns (DeleteActorMutelistSubscriptionResponse); + rpc ClearActorMutelistSubscriptions(ClearActorMutelistSubscriptionsRequest) returns (ClearActorMutelistSubscriptionsResponse); +} + + +message TakedownActorRequest { + string did = 1; + string ref = 2; + google.protobuf.Timestamp seen = 3; +} + +message TakedownActorResponse { +} + +message UntakedownActorRequest { + string did = 1; + google.protobuf.Timestamp seen = 2; +} + +message UntakedownActorResponse { +} + +message TakedownBlobRequest { + string did = 1; + string cid = 2; + string ref = 3; + google.protobuf.Timestamp seen = 4; +} + +message TakedownBlobResponse {} + +message UntakedownBlobRequest { + string did = 1; + string cid = 2; + google.protobuf.Timestamp seen = 3; +} + +message UntakedownBlobResponse {} + +message TakedownRecordRequest { + string record_uri = 1; + string ref = 2; + google.protobuf.Timestamp seen = 3; +} + +message TakedownRecordResponse { +} + +message UntakedownRecordRequest { + string record_uri = 1; + google.protobuf.Timestamp seen = 2; +} + +message UntakedownRecordResponse { +} + +message CreateActorMuteRequest { + string actor_did = 1; + string subject_did = 2; +} + +message CreateActorMuteResponse {} + +message DeleteActorMuteRequest { + string actor_did = 1; + string subject_did = 2; +} + +message DeleteActorMuteResponse {} + +message ClearActorMutesRequest { + string actor_did = 1; +} + +message ClearActorMutesResponse {} + +message CreateActorMutelistSubscriptionRequest { + string actor_did = 1; + string subject_uri = 2; +} + +message CreateActorMutelistSubscriptionResponse {} + +message DeleteActorMutelistSubscriptionRequest { + string actor_did = 1; + string subject_uri = 2; +} + +message DeleteActorMutelistSubscriptionResponse {} + +message ClearActorMutelistSubscriptionsRequest { + string actor_did = 1; +} + +message ClearActorMutelistSubscriptionsResponse {} diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 47c2f8f8ca7..0803d2c2d31 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,115 +1,98 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile' -import { softDeleted } from '../../../../db/util' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { - ActorService, - ProfileDetailHydrationState, -} from '../../../../services/actor' -import { setRepoRev } from '../../../util' +import { resHeaders } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' -import { ModerationService } from '../../../../services/moderation' +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 }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const modService = ctx.services.moderation(ctx.db.getPrimary()) - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + handler: async ({ auth, params, req }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + includeTakedowns, + }) - const [result, repoRev] = await Promise.allSettled([ - getProfile( - { ...params, viewer, canViewTakedowns }, - { db, actorService, modService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getProfile({ ...params, hydrateCtx }, ctx) - if (repoRev.status === 'fulfilled') { - setRepoRev(res, repoRev.value) - } - if (result.status === 'rejected') { - throw result.reason - } + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', - body: result.value, + body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { actorService } = ctx - const { canViewTakedowns } = params - const actor = await actorService.getActor(params.actor, true) - if (!actor) { +const skeleton = async (input: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = input + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { throw new InvalidRequestError('Profile not found') } - if (!canViewTakedowns && softDeleted(actor)) { - if (actor.takedownRef?.includes('SUSPEND')) { - throw new InvalidRequestError( - 'Account has been temporarily suspended', - 'AccountTakedown', - ) - } else { - throw new InvalidRequestError( - 'Account has been taken down', - 'AccountTakedown', - ) - } - } - return { params, actor } + return { did } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, actor } = state - const { viewer, canViewTakedowns } = params - const hydration = await actorService.views.profileDetailHydration( - [actor.did], - { viewer, includeSoftDeleted: canViewTakedowns }, +const hydration = async (input: { + ctx: Context + params: Params + skeleton: SkeletonState +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed( + [skeleton.did], + params.hydrateCtx.copy({ includeTakedowns: true }), ) - return { ...state, ...hydration } } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService } = ctx - const { params, actor } = state - const { viewer } = params - const profiles = actorService.views.profileDetailPresentation( - [actor.did], - state, - { viewer }, - ) - const profile = profiles[actor.did] +const presentation = (input: { + ctx: Context + params: Params + skeleton: SkeletonState + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = input + const profile = ctx.views.profileDetailed(skeleton.did, hydration) if (!profile) { throw new InvalidRequestError('Profile not found') + } else if ( + !params.hydrateCtx.includeTakedowns && + ctx.views.actorIsTakendown(skeleton.did, hydration) + ) { + throw new InvalidRequestError( + 'Account has been suspended', + 'AccountTakedown', + ) } return profile } type Context = { - db: Database - actorService: ActorService - modService: ModerationService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string | null - canViewTakedowns: boolean + hydrateCtx: HydrateCtx } -type SkeletonState = { params: Params; actor: Actor } - -type HydrationState = SkeletonState & ProfileDetailHydrationState +type SkeletonState = { did: string } diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 21ca13949d2..81fb34e5ea5 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -2,77 +2,78 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { - ActorService, - ProfileDetailHydrationState, -} from '../../../../services/actor' -import { setRepoRev } from '../../../util' +import { resHeaders } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' +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 }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) + handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers }) - const [result, repoRev] = await Promise.all([ - getProfile({ ...params, viewer }, { db, actorService }), - actorService.getRepoRev(viewer), - ]) + const result = await getProfile({ ...params, hydrateCtx }, ctx) - setRepoRev(res, repoRev) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { actorService } = ctx - const actors = await actorService.getActors(params.actors) - return { params, dids: actors.map((a) => a.did) } +const skeleton = async (input: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = input + const dids = await ctx.hydrator.actor.getDidsDefined(params.actors) + return { dids } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, dids } = state - const { viewer } = params - const hydration = await actorService.views.profileDetailHydration(dids, { - viewer, - }) - return { ...state, ...hydration } +const hydration = async (input: { + ctx: Context + params: Params + skeleton: SkeletonState +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.hydrateCtx) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService } = ctx - const { params, dids } = state - const { viewer } = params - const profiles = actorService.views.profileDetailPresentation(dids, state, { - viewer, - }) - const profileViews = mapDefined(dids, (did) => profiles[did]) - return { profiles: profileViews } +const presentation = (input: { + ctx: Context + params: Params + skeleton: SkeletonState + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + const profiles = mapDefined(skeleton.dids, (did) => + ctx.views.profileDetailed(did, hydration), + ) + return { profiles } } type Context = { - db: Database - actorService: ActorService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string | null + hydrateCtx: HydrateCtx } -type SkeletonState = { params: Params; dids: string[] } - -type HydrationState = SkeletonState & ProfileDetailHydrationState +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 3bfeccfa28e..da86489a69d 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,13 +1,17 @@ import { mapDefined } from '@atproto/common' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { notSoftDeletedClause } from '../../../../db/util' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions' import { createPipeline } from '../../../../pipeline' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getSuggestions = createPipeline( @@ -18,133 +22,92 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.actor.getSuggestions({ auth: ctx.authVerifier.standardOptional, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - - const result = await getSuggestions( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers }) + const result = await getSuggestions({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { viewer } = params - const alreadyIncluded = parseCursor(params.cursor) // @NOTE handles bad cursor e.g. on appview swap - const { ref } = db.db.dynamic - const suggestions = await db.db - .selectFrom('suggested_follow') - .innerJoin('actor', 'actor.did', 'suggested_follow.did') - .where(notSoftDeletedClause(ref('actor'))) - .where('suggested_follow.did', '!=', viewer ?? '') - .whereNotExists((qb) => - qb - .selectFrom('follow') - .selectAll() - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')), - ) - .if(alreadyIncluded.length > 0, (qb) => - qb.where('suggested_follow.order', 'not in', alreadyIncluded), - ) - .selectAll() - .orderBy('suggested_follow.order', 'asc') - .execute() - - // always include first two - const firstTwo = suggestions.filter( - (row) => row.order === 1 || row.order === 2, - ) - const rest = suggestions.filter((row) => row.order !== 1 && row.order !== 2) - const limited = firstTwo.concat(shuffle(rest)).slice(0, params.limit) - - // if the result set ends up getting larger, consider using a seed included in the cursor for for the randomized shuffle - const cursor = - limited.length > 0 - ? limited - .map((row) => row.order.toString()) - .concat(alreadyIncluded.map((id) => id.toString())) - .join(':') - : undefined - - return { params, suggestions: limited, cursor } +const skeleton = async (input: { + ctx: Context + 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: viewer ?? undefined, + cursor: params.cursor, + limit: params.limit, + }) + let dids = suggestions.dids + if (viewer !== null) { + const follows = await ctx.dataplane.getActorFollowsActors({ + actorDid: viewer, + targetDids: dids, + }) + dids = dids.filter((did, i) => !follows.uris[i] && did !== viewer) + } + return { dids, cursor: parseString(suggestions.cursor) } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, suggestions } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(suggestions, viewer), - graphService.getBlockAndMuteState( - viewer ? suggestions.map((sug) => [viewer, sug.did]) : [], - ), - ]) - return { ...state, bam, actors } +const hydration = async (input: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfilesDetailed(skeleton.dids, params.hydrateCtx) } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.suggestions = state.suggestions.filter( - (item) => - !state.bam.block([viewer, item.did]) && - !state.bam.mute([viewer, item.did]), +const noBlocksOrMutes = (input: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + skeleton.dids = skeleton.dids.filter( + (did) => + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration), ) - return state -} - -const presentation = (state: HydrationState) => { - const { suggestions, actors, cursor } = state - const suggestedActors = mapDefined(suggestions, (sug) => actors[sug.did]) - return { actors: suggestedActors, cursor } + return skeleton } -const parseCursor = (cursor?: string): number[] => { - if (!cursor) { - return [] - } - try { - return cursor - .split(':') - .map((id) => parseInt(id, 10)) - .filter((id) => !isNaN(id)) - } catch { - return [] +const presentation = (input: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = input + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ) + return { + actors, + cursor: skeleton.cursor, } } -const shuffle = (arr: T[]): T[] => { - return arr - .map((value) => ({ value, sort: Math.random() })) - .sort((a, b) => a.sort - b.sort) - .map(({ value }) => value) -} - type Context = { - db: Database - actorService: ActorService - graphService: GraphService + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } - -type SkeletonState = { params: Params; suggestions: Actor[]; cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap +type Params = QueryParams & { + 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 82f0327ef89..c54a72f3f1d 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -1,56 +1,119 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import { cleanQuery } from '../../../../services/util/search' +import { mapDefined } from '@atproto/common' +import AtpAgent from '@atproto/api' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActors' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { + const searchActors = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) server.app.bsky.actor.searchActors({ auth: ctx.authVerifier.standardOptional, - handler: async ({ auth, params }) => { - const { cursor, limit } = params - const requester = auth.credentials.iss - const rawQuery = params.q ?? params.term - const query = cleanQuery(rawQuery || '') - const db = ctx.db.getReplica('search') - - let results: string[] - let resCursor: string | undefined - if (ctx.searchAgent) { - // @NOTE cursors wont change on appview swap - const res = - await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ - q: query, - cursor, - limit, - }) - results = res.data.actors.map((a) => a.did) - resCursor = res.data.cursor - } else { - const res = await ctx.services - .actor(ctx.db.getReplica('search')) - .getSearchResults({ query, limit, cursor }) - results = res.results.map((a) => a.did) - resCursor = res.cursor - } - - const actors = await ctx.services - .actor(db) - .views.profiles(results, requester) - - const SKIP = [] - const filtered = results.flatMap((did) => { - const actor = actors[did] - if (!actor) return SKIP - if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP - return actor + handler: async ({ auth, params, req }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + viewer, + labelers, + includeTakedowns, }) - + const results = await searchActors({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', - body: { - cursor: resCursor, - actors: filtered, - }, + body: results, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } + +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + const term = params.q ?? params.term ?? '' + + // @TODO + // add hits total + + if (ctx.searchAgent) { + // @NOTE cursors wont change on appview swap + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + q: term, + cursor: params.cursor, + limit: params.limit, + }) + return { + dids: res.actors.map(({ did }) => did), + cursor: parseString(res.cursor), + } + } + + const res = await ctx.dataplane.searchActors({ + term, + limit: params.limit, + cursor: params.cursor, + }) + return { + dids: res.dids, + cursor: parseString(res.cursor), + } +} + +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateProfiles(skeleton.dids, params.hydrateCtx) +} + +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.dids = skeleton.dids.filter( + (did) => !ctx.views.viewerBlockExists(did, hydration), + ) + return skeleton +} + +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ) + return { + actors, + cursor: skeleton.cursor, + } +} + +type Context = { + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent +} + +type Params = QueryParams & { hydrateCtx: HydrateCtx } + +type Skeleton = { + dids: string[] + hitsTotal?: number + cursor?: string +} diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 6a3167fd2d0..ce64ab49648 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,56 +1,114 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' +import AtpAgent from '@atproto/api' +import { mapDefined } from '@atproto/common' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActorsTypeahead' import { - cleanQuery, - getUserSearchQuerySimple, -} from '../../../../services/util/search' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { + const searchActorsTypeahead = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.authVerifier.standardOptional, - handler: async ({ params, auth }) => { - const { limit } = params - const requester = auth.credentials.iss - const rawQuery = params.q ?? params.term - const query = cleanQuery(rawQuery || '') - const db = ctx.db.getReplica('search') - - let results: string[] - if (ctx.searchAgent) { - const res = - await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ - q: query, - typeahead: true, - limit, - }) - results = res.data.actors.map((a) => a.did) - } else { - const res = query - ? await getUserSearchQuerySimple(db, { query, limit }) - .selectAll('actor') - .execute() - : [] - results = res.map((a) => a.did) + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const results = await searchActorsTypeahead( + { ...params, hydrateCtx }, + ctx, + ) + return { + encoding: 'application/json', + body: results, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } + }, + }) +} + +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + const term = params.q ?? params.term ?? '' - const actors = await ctx.services - .actor(db) - .views.profilesBasic(results, requester) + // @TODO + // add typeahead option + // add hits total - const SKIP = [] - const filtered = results.flatMap((did) => { - const actor = actors[did] - if (!actor) return SKIP - if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP - return actor + if (ctx.searchAgent) { + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + typeahead: true, + q: term, + limit: params.limit, }) + return { + dids: res.actors.map(({ did }) => did), + cursor: parseString(res.cursor), + } + } - return { - encoding: 'application/json', - body: { - actors: filtered, - }, - } - }, + const res = await ctx.dataplane.searchActors({ + term, + limit: params.limit, }) + return { + dids: res.dids, + cursor: parseString(res.cursor), + } +} + +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateProfilesBasic(skeleton.dids, params.hydrateCtx) +} + +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.dids = skeleton.dids.filter( + (did) => !ctx.views.viewerBlockExists(did, hydration), + ) + return skeleton +} + +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const actors = mapDefined(skeleton.dids, (did) => + ctx.views.profileBasic(did, hydration), + ) + return { + actors, + } +} + +type Context = { + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent +} + +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 266839c7711..55624324aad 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,69 +1,101 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorFeeds' import AppContext from '../../../../context' -import { TimeCidKeyset, paginate } from '../../../../db/pagination' +import { createPipeline, noRules } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getActorFeeds = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.feed.getActorFeeds({ auth: ctx.authVerifier.standardOptional, - handler: async ({ auth, params }) => { - const { actor, limit, cursor } = params + handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { feeds: [] }, - } + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getActorFeeds({ ...params, hydrateCtx }, ctx) + return { + encoding: 'application/json', + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } + }, + }) +} - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - - const creatorRes = await actorService.getActor(actor) - if (!creatorRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { feedUris: [] } + } + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { + throw new InvalidRequestError('Profile not found') + } + const feedsRes = await ctx.dataplane.getActorFeeds({ + actorDid: did, + cursor: params.cursor, + limit: params.limit, + }) + return { + feedUris: feedsRes.uris, + cursor: parseString(feedsRes.cursor), + } +} - const { ref } = db.db.dynamic - let feedsQb = feedService - .selectFeedGeneratorQb(viewer) - .where('feed_generator.creator', '=', creatorRes.did) +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedGens( + skeleton.feedUris, + params.hydrateCtx, + ) +} - const keyset = new TimeCidKeyset( - ref('feed_generator.createdAt'), - ref('feed_generator.cid'), - ) - feedsQb = paginate(feedsQb, { - limit, - cursor, - keyset, - }) +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feeds = mapDefined(skeleton.feedUris, (uri) => + ctx.views.feedGenerator(uri, hydration), + ) + return { + feeds, + cursor: skeleton.cursor, + } +} - const [feedsRes, profiles] = await Promise.all([ - feedsQb.execute(), - actorService.views.profiles([creatorRes], viewer), - ]) - if (!profiles[creatorRes.did]) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +type Context = { + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient +} - const feeds = mapDefined(feedsRes, (row) => { - const feed = { - ...row, - viewer: viewer ? { like: row.viewerLike } : undefined, - } - return feedService.views.formatFeedGeneratorView(feed, profiles) - }) +type Params = QueryParams & { hydrateCtx: HydrateCtx } - return { - encoding: 'application/json', - body: { - cursor: keyset.packFromResult(feedsRes), - feeds, - }, - } - }, - }) +type Skeleton = { + feedUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 48d6437e494..d3903a57343 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -1,19 +1,20 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorLikes' -import { FeedKeyset } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { Database } from '../../../../db' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, resHeaders } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getActorLikes = createPipeline( @@ -24,106 +25,102 @@ 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 }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const [result, repoRev] = await Promise.all([ - getActorLikes( - { ...params, viewer }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getActorLikes({ ...params, hydrateCtx }, ctx) - setRepoRev(res, repoRev) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService, feedService } = ctx - const { actor, limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - const actorRes = await actorService.getActor(actor) - if (!actorRes) { - throw new InvalidRequestError('Profile not found') +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + const { actor, limit, cursor } = params + const viewer = params.hydrateCtx.viewer + if (clearlyBadCursor(cursor)) { + return { items: [] } } - const actorDid = actorRes.did - - if (!viewer || viewer !== actorDid) { + const [actorDid] = await ctx.hydrator.actor.getDids([actor]) + if (!actorDid || !viewer || viewer !== actorDid) { throw new InvalidRequestError('Profile not found') } - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } - } - - let feedItemsQb = feedService - .selectFeedItemQb() - .innerJoin('like', 'like.subject', 'feed_item.uri') - .where('like.creator', '=', actorDid) - - const keyset = new FeedKeyset(ref('like.sortAt'), ref('like.cid')) - - feedItemsQb = paginate(feedItemsQb, { + const likesRes = await ctx.dataplane.getActorLikes({ + actorDid, limit, cursor, - keyset, }) - const feedItems = await feedItemsQb.execute() + const items = likesRes.likes.map((l) => ({ post: { uri: l.subject } })) - return { params, feedItems, cursor: keyset.packFromResult(feedItems) } + return { + items, + cursor: parseString(likesRes.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx) } -const noPostBlocks = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter( - (item) => !viewer || !state.bam.block([viewer, item.postAuthorDid]), - ) - return state +const noPostBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const creator = creatorFromUri(item.post.uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { + feed, + cursor: skeleton.cursor, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { params: Params; feedItems: FeedRow[]; cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState +type Skeleton = { + items: FeedItem[] + cursor?: string +} diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 6c783efdd0c..f32712aa45d 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -1,19 +1,22 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' -import { FeedKeyset } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, resHeaders } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, + mergeStates, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { Actor } from '../../../../hydration/actor' +import { FeedItem, Post } from '../../../../hydration/feed' +import { FeedType } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { const getAuthorFeed = createPipeline( @@ -24,156 +27,206 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.feed.getAuthorFeed({ auth: ctx.authVerifier.optionalStandardOrRole, - handler: async ({ params, auth, res }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) - const { viewer } = ctx.authVerifier.parseCreds(auth) - - const [result, repoRev] = await Promise.all([ - getAuthorFeed( - { ...params, viewer }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) - - setRepoRev(res, repoRev) + handler: async ({ params, auth, req }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + includeTakedowns, + }) + + const result = await getAuthorFeed({ ...params, hydrateCtx }, ctx) + + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), } }, }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { cursor, limit, actor, filter, viewer } = params - const { db, actorService, feedService, graphService } = ctx - const { ref } = db.db.dynamic - - // maybe resolve did first - const actorRes = await actorService.getActor(actor) - if (!actorRes) { +const FILTER_TO_FEED_TYPE = { + posts_with_replies: undefined, // default: all posts, replies, and reposts + posts_no_replies: FeedType.POSTS_NO_REPLIES, + posts_with_media: FeedType.POSTS_WITH_MEDIA, + posts_and_author_threads: FeedType.POSTS_AND_AUTHOR_THREADS, +} + +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + const [did] = await ctx.hydrator.actor.getDids([params.actor]) + if (!did) { throw new InvalidRequestError('Profile not found') } - const actorDid = actorRes.did - - // verify there is not a block between requester & subject - if (viewer !== null) { - const blocks = await graphService.getBlockState([[viewer, actorDid]]) - if (blocks.blocking([viewer, actorDid])) { - throw new InvalidRequestError( - `Requester has blocked actor: ${actor}`, - 'BlockedActor', - ) - } - if (blocks.blockedBy([viewer, actorDid])) { - throw new InvalidRequestError( - `Requester is blocked by actor: $${actor}`, - 'BlockedByActor', - ) - } - } - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } + const actors = await ctx.hydrator.actor.getActors( + [did], + params.hydrateCtx.includeTakedowns, + ) + const actor = actors.get(did) + if (!actor) { + throw new InvalidRequestError('Profile not found') } - - // defaults to posts, reposts, and replies - let feedItemsQb = feedService - .selectFeedItemQb() - .where('originatorDid', '=', actorDid) - - if (filter === 'posts_with_media') { - feedItemsQb = feedItemsQb - // only your own posts - .where('type', '=', 'post') - // only posts with media - .whereExists((qb) => - qb - .selectFrom('post_embed_image') - .select('post_embed_image.postUri') - .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), - ) - } else if (filter === 'posts_no_replies') { - feedItemsQb = feedItemsQb.where((qb) => - qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'), - ) - } else if (filter === 'posts_and_author_threads') { - feedItemsQb = feedItemsQb.where((qb) => - qb - .where('type', '=', 'repost') - .orWhere('post.replyParent', 'is', null) - .orWhere('post.replyRoot', 'like', `at://${actorDid}/%`), - ) + if (clearlyBadCursor(params.cursor)) { + return { actor, filter: params.filter, items: [] } } - - const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - - feedItemsQb = paginate(feedItemsQb, { - limit, - cursor, - keyset, + const res = await ctx.dataplane.getAuthorFeed({ + actorDid: did, + limit: params.limit, + cursor: params.cursor, + feedType: FILTER_TO_FEED_TYPE[params.filter], }) - - const feedItems = await feedItemsQb.execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + actor, + filter: params.filter, + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + const [feedPostState, profileViewerState] = await Promise.all([ + ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx), + ctx.hydrator.hydrateProfileViewers([skeleton.actor.did], params.hydrateCtx), + ]) + return mergeStates(feedPostState, profileViewerState) } -const noBlocksOrMutedReposts = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true +const noBlocksOrMutedReposts = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + const relationship = hydration.profileViewers?.get(skeleton.actor.did) + if (relationship?.blocking || relationship?.blockingByList) { + throw new InvalidRequestError( + `Requester has blocked actor: ${skeleton.actor.did}`, + 'BlockedActor', + ) + } + if (relationship?.blockedBy || relationship?.blockedByList) { + throw new InvalidRequestError( + `Requester is blocked by actor: ${skeleton.actor.did}`, + 'BlockedByActor', + ) + } + // for posts_and_author_threads, ensure replies are only included if the feed + // contains all replies up to the thread root (i.e. a complete self-thread.) + const selfThread = + skeleton.filter === 'posts_and_author_threads' + ? new SelfThreadTracker(skeleton.items, hydration) + : undefined + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( - !state.bam.block([viewer, item.postAuthorDid]) && - (item.type === 'post' || !state.bam.mute([viewer, item.postAuthorDid])) + !bam.authorBlocked && + !bam.originatorBlocked && + (!bam.authorMuted || bam.originatorMuted) && + (!selfThread || selfThread.eligible(item.post.uri)) ) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - actorService: ActorService - feedService: FeedService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { + hydrateCtx: HydrateCtx +} -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + actor: Actor + items: FeedItem[] + filter: QueryParams['filter'] cursor?: string } -type HydrationState = SkeletonState & FeedHydrationState +class SelfThreadTracker { + feedUris = new Set() + cache = new Map() + + constructor(items: FeedItem[], private hydration: HydrationState) { + items.forEach((item) => { + if (!item.repost) { + this.feedUris.add(item.post.uri) + } + }) + } + + eligible(uri: string, loop = new Set()) { + // if we've already checked this uri, pull from the cache + if (this.cache.has(uri)) { + return this.cache.get(uri) ?? false + } + // loop detection + if (loop.has(uri)) { + this.cache.set(uri, false) + return false + } else { + loop.add(uri) + } + // cache through the result + const result = this._eligible(uri, loop) + this.cache.set(uri, result) + return result + } + + private _eligible(uri: string, loop: Set): boolean { + // must be in the feed to be in a self-thread + if (!this.feedUris.has(uri)) { + return false + } + // must be hydratable to be part of self-thread + const post = this.hydration.posts?.get(uri) + if (!post) { + return false + } + // root posts (no parent) are trivial case of self-thread + const parentUri = getParentUri(post) + if (parentUri === null) { + return true + } + // recurse w/ cache: this post is in a self-thread if its parent is. + return this.eligible(parentUri, loop) + } +} + +function getParentUri(post: Post) { + return post.record.reply?.parent.uri ?? null +} diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 66461cd3bbb..b4c85ad7f2c 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -1,3 +1,4 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError, UpstreamFailureError, @@ -5,25 +6,29 @@ import { serverTimingHeader, } from '@atproto/xrpc-server' import { ResponseType, XRPCError } from '@atproto/xrpc' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { AtpAgent, AppBskyFeedGetFeedSkeleton } from '@atproto/api' import { noUndefinedVals } from '@atproto/common' import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky/feed/getFeed' import { OutputSchema as SkeletonOutput } from '../../../../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { SkeletonFeedPost } from '../../../../lexicon/types/app/bsky/feed/defs' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Database } from '../../../../db' import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { createPipeline } from '../../../../pipeline' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { HydrateCtx } from '../../../../hydration/hydrator' +import { FeedItem } from '../../../../hydration/feed' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getFeed = createPipeline( @@ -35,29 +40,27 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeed({ auth: ctx.authVerifier.standardOptionalAnyAud, handler: async ({ params, auth, req }) => { - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ 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 }, - { - db, - feedService, - appCtx: ctx, - headers, - }, - ) + const { + timerSkele, + timerHydr, + resHeaders: feedResHeaders, + ...result + } = await getFeed({ ...params, hydrateCtx, headers }, ctx) return { encoding: 'application/json', body: result, headers: { - ...(resHeaders ?? {}), + ...(feedResHeaders ?? {}), + ...resHeaders({ labelers: hydrateCtx.labelers }), 'server-timing': serverTimingHeader([timerSkele, timerHydr]), }, } @@ -66,125 +69,113 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { + inputs: SkeletonFnInput, +): Promise => { + const { ctx, params } = inputs const timerSkele = new ServerTimer('skele').start() - const feedParams: GetFeedParams = { - feed: params.feed, - limit: params.limit, - cursor: params.cursor, - } - const { feedItems, cursor, resHeaders, ...passthrough } = - await skeletonFromFeedGen(ctx, feedParams) + const { + feedItems: algoItems, + cursor, + resHeaders, + ...passthrough + } = await skeletonFromFeedGen(ctx, params) + return { - params, cursor, - feedItems, + items: algoItems.map(toFeedItem), timerSkele: timerSkele.stop(), + timerHydr: new ServerTimer('hydr').start(), resHeaders, passthrough, } } -const hydration = async (state: SkeletonState, ctx: Context) => { +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs const timerHydr = new ServerTimer('hydr').start() - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated, timerHydr: timerHydr.stop() } + const hydration = await ctx.hydrator.hydrateFeedItems( + skeleton.items, + params.hydrateCtx, + ) + skeleton.timerHydr = timerHydr.stop() + return hydration } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true +const noBlocksOrMutes = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.block([viewer, item.originatorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.originatorDid]) + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted ) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, passthrough, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, params, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => { + return ctx.views.feedViewPost(item, hydration) + }).slice(0, params.limit) return { feed, - cursor, - timerSkele: state.timerSkele, - timerHydr: state.timerHydr, - resHeaders: state.resHeaders, - ...passthrough, + cursor: skeleton.cursor, + timerSkele: skeleton.timerSkele, + timerHydr: skeleton.timerHydr, + resHeaders: skeleton.resHeaders, + ...skeleton.passthrough, } } -type Context = { - db: Database - feedService: FeedService - appCtx: AppContext +type Context = AppContext + +type Params = GetFeedParams & { + hydrateCtx: HydrateCtx headers: Record } -type Params = GetFeedParams & { viewer: string | null } - -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] passthrough: Record // pass through additional items in feedgen response resHeaders?: Record cursor?: string timerSkele: ServerTimer -} - -type HydrationState = SkeletonState & - FeedHydrationState & { feedItems: FeedRow[]; timerHydr: ServerTimer } - -type AlgoResponse = { - feedItems: FeedRow[] - resHeaders?: Record - cursor?: string + timerHydr: ServerTimer } const skeletonFromFeedGen = async ( ctx: Context, - params: GetFeedParams, + params: Params, ): Promise => { - const { db, appCtx, headers } = ctx - const { feed } = params - // Resolve and fetch feed skeleton - const found = await db.db - .selectFrom('feed_generator') - .where('uri', '=', feed) - .select('feedDid') - .executeTakeFirst() - if (!found) { + const { feed, headers } = params + const found = await ctx.hydrator.feed.getFeedGens([feed], true) + const feedDid = await found.get(feed)?.record.did + if (!feedDid) { throw new InvalidRequestError('could not find feed') } - const feedDid = found.feedDid - let resolved: DidDocument | null + let identity: GetIdentityByDidResponse try { - resolved = await appCtx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError(`could not resolve identity: ${feedDid}`) } throw err } - if (!resolved) { - throw new InvalidRequestError(`could not resolve did document: ${feedDid}`) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, @@ -197,9 +188,16 @@ const skeletonFromFeedGen = async ( let resHeaders: Record | undefined = undefined try { // @TODO currently passthrough auth headers from pds - const result = await agent.api.app.bsky.feed.getFeedSkeleton(params, { - headers, - }) + const result = await agent.api.app.bsky.feed.getFeedSkeleton( + { + feed: params.feed, + limit: params.limit, + cursor: params.cursor, + }, + { + headers, + }, + ) skeleton = result.data if (result.headers['content-language']) { resHeaders = { @@ -225,33 +223,30 @@ const skeletonFromFeedGen = async ( } const { feed: feedSkele, ...skele } = skeleton - const feedItems = await skeletonToFeedItems( - feedSkele.slice(0, params.limit), - ctx, - ) + const feedItems = feedSkele.map((item) => ({ + itemUri: + typeof item.reason?.repost === 'string' ? item.reason.repost : item.post, + postUri: item.post, + })) return { ...skele, resHeaders, feedItems } } -const skeletonToFeedItems = async ( - skeleton: SkeletonFeedPost[], - ctx: Context, -): Promise => { - const { feedService } = ctx - const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItemsRaw = await feedService.getFeedItems(feedItemUris) - const results: FeedRow[] = [] - for (const skeleItem of skeleton) { - const feedItem = feedItemsRaw[getSkeleFeedItemUri(skeleItem)] - if (feedItem && feedItem.postUri === skeleItem.post) { - results.push(feedItem) - } - } - return results +export type AlgoResponse = { + feedItems: AlgoResponseItem[] + resHeaders?: Record + cursor?: string } -const getSkeleFeedItemUri = (item: SkeletonFeedPost) => { - return typeof item.reason?.repost === 'string' - ? item.reason.repost - : item.post +export type AlgoResponseItem = { + itemUri: string + postUri: string } + +export const toFeedItem = (feedItem: AlgoResponseItem): FeedItem => ({ + post: { uri: feedItem.postUri }, + repost: + feedItem.itemUri === feedItem.postUri + ? undefined + : { uri: feedItem.itemUri }, +}) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 125af1db9b9..647bfdd090b 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,60 +1,54 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { - DidDocument, - PoorlyFormattedDidDocumentError, - getFeedGen, -} from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../../../../data-plane' +import { resHeaders } from '../../../util' 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 db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - - const got = await feedService.getFeedGeneratorInfos([feed], viewer) - const feedInfo = got[feed] + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const hydration = await ctx.hydrator.hydrateFeedGens([feed], hydrateCtx) + const feedInfo = hydration.feedgens?.get(feed) if (!feedInfo) { throw new InvalidRequestError('could not find feed') } - const feedDid = feedInfo.feedDid - let resolved: DidDocument | null + const feedDid = feedInfo.record.did + let identity: GetIdentityByDidResponse try { - resolved = await ctx.idResolver.did.resolve(feedDid) + identity = await ctx.dataplane.getIdentityByDid({ did: feedDid }) } catch (err) { - if (err instanceof PoorlyFormattedDidDocumentError) { - throw new InvalidRequestError(`invalid did document: ${feedDid}`) + if (isDataplaneError(err, Code.NotFound)) { + throw new InvalidRequestError( + `could not resolve identity: ${feedDid}`, + ) } throw err } - if (!resolved) { - throw new InvalidRequestError( - `could not resolve did document: ${feedDid}`, - ) - } - const fgEndpoint = getFeedGen(resolved) + const services = unpackIdentityServices(identity.services) + const fgEndpoint = getServiceEndpoint(services, { + id: 'bsky_fg', + type: 'BskyFeedGenerator', + }) if (!fgEndpoint) { throw new InvalidRequestError( `invalid feed generator service details in did document: ${feedDid}`, ) } - const profiles = await actorService.views.profilesBasic( - [feedInfo.creator], - viewer, - ) - const feedView = feedService.views.formatFeedGeneratorView( - feedInfo, - profiles, - ) + const feedView = ctx.views.feedGenerator(feed, hydration) if (!feedView) { throw new InvalidRequestError('could not find feed') } @@ -67,6 +61,7 @@ export default function (server: Server, ctx: AppContext) { isOnline: true, isValid: true, }, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index ed6df5760cb..9bf3eaf267d 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,10 +1,15 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getFeedGenerators' import AppContext from '../../../../context' -import { FeedGenInfo, FeedService } from '../../../../services/feed' import { createPipeline, noRules } from '../../../../pipeline' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { Database } from '../../../../db' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getFeedGenerators = createPipeline( @@ -15,66 +20,59 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.feed.getFeedGenerators({ auth: ctx.authVerifier.standardOptional, - handler: async ({ params, auth }) => { - const { feeds } = params + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - - const view = await getFeedGenerators( - { feeds, viewer }, - { db, feedService, actorService }, - ) - + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const view = await getFeedGenerators({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: view, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async (params: Params, ctx: Context) => { - const { feedService } = ctx - const genInfos = await feedService.getFeedGeneratorInfos( - params.feeds, - params.viewer, - ) +const skeleton = async (inputs: { params: Params }): Promise => { return { - params, - generators: Object.values(genInfos), + feedUris: inputs.params.feeds, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const profiles = await actorService.views.profilesBasic( - state.generators.map((gen) => gen.creator), - state.params.viewer, +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateFeedGens( + skeleton.feedUris, + params.hydrateCtx, ) - return { - ...state, - profiles, - } } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const feeds = mapDefined(state.generators, (gen) => - feedService.views.formatFeedGeneratorView(gen, state.profiles), +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feeds = mapDefined(skeleton.feedUris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) - return { feeds } + return { + feeds, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + hydrator: Hydrator + views: Views } -type Params = { viewer: string | null; feeds: string[] } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { params: Params; generators: FeedGenInfo[] } - -type HydrationState = SkeletonState & { profiles: ActorInfoMap } +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 2d59656a517..7e179158dfc 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,131 +1,118 @@ import { mapDefined } from '@atproto/common' +import { normalizeDatetimeAlways } from '@atproto/syntax' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { Actor } from '../../../../db/tables/actor' -import { Database } from '../../../../db' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { clearlyBadCursor, resHeaders } from '../../../util' 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 }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - - const result = await getLikes( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getLikes({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { uri, cid, limit, cursor } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, likes: [] } - } - - let builder = db.db - .selectFrom('like') - .where('like.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'like.creator') - .where(notSoftDeletedClause(ref('creator'))) - .selectAll('creator') - .select([ - 'like.cid as cid', - 'like.createdAt as createdAt', - 'like.indexedAt as indexedAt', - 'like.sortAt as sortAt', - ]) - - if (cid) { - builder = builder.where('like.subjectCid', '=', cid) +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { likes: [] } } - - const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, + const likesRes = await ctx.hydrator.dataplane.getLikesBySubject({ + subject: { uri: params.uri, cid: params.cid }, + cursor: params.cursor, + limit: params.limit, }) - - const likes = await builder.execute() - - return { params, likes, cursor: keyset.packFromResult(likes) } + return { + likes: likesRes.uris, + cursor: parseString(likesRes.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, likes } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(likes, viewer), - graphService.getBlockAndMuteState( - viewer ? likes.map((like) => [viewer, like.did]) : [], - ), - ]) - return { ...state, bam, actors } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateLikes(skeleton.likes, params.hydrateCtx) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.likes = state.likes.filter( - (item) => !state.bam.block([viewer, item.did]), - ) - return state +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.likes = skeleton.likes.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, likes, actors, cursor } = state - const { uri, cid } = params - const likesView = mapDefined(likes, (like) => - actors[like.did] - ? { - createdAt: like.createdAt, - indexedAt: like.indexedAt, - actor: actors[like.did], - } - : undefined, - ) - return { likes: likesView, cursor, uri, cid } +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = inputs + const likeViews = mapDefined(skeleton.likes, (uri) => { + const like = hydration.likes?.get(uri) + if (!like || !like.record) { + return + } + const creatorDid = creatorFromUri(uri) + const actor = ctx.views.profile(creatorDid, hydration) + if (!actor) { + return + } + return { + actor, + createdAt: normalizeDatetimeAlways(like.record.createdAt), + indexedAt: like.sortedAt.toISOString(), + } + }) + return { + likes: likeViews, + cursor: skeleton.cursor, + uri: params.uri, + cid: params.cid, + } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - likes: (Actor & { createdAt: string })[] +type Skeleton = { + likes: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 478d9b08efa..5250865cfc2 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -1,18 +1,18 @@ import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getListFeed' -import { FeedKeyset, getFeedDateThreshold } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { setRepoRev } from '../../../util' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { GraphService } from '../../../../services/graph' +import { clearlyBadCursor, resHeaders } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { mapDefined } from '@atproto/common' +import { parseString } from '../../../../hydration/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getListFeed = createPipeline( @@ -23,109 +23,96 @@ 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 }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const [result, repoRev] = await Promise.all([ - getListFeed( - { ...params, viewer }, - { db, actorService, feedService, graphService }, - ), - actorService.getRepoRev(viewer), - ]) + const result = await getListFeed({ ...params, hydrateCtx }, ctx) - setRepoRev(res, repoRev) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers, repoRev }), } }, }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { list, cursor, limit } = params - const { db } = ctx - const { ref } = db.db.dynamic - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { items: [] } } - - const keyset = new FeedKeyset(ref('post.sortAt'), ref('post.cid')) - const sortFrom = keyset.unpack(cursor)?.primary - - let builder = ctx.feedService - .selectPostQb() - .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') - .where('list_item.listUri', '=', list) - .where('post.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) - - builder = paginate(builder, { - limit, - cursor, - keyset, - tryIndex: true, + const res = await ctx.dataplane.getListFeed({ + listUri: params.list, + limit: params.limit, + cursor: params.cursor, }) - const feedItems = await builder.execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx) } -const noBlocksOrMutes = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.feedItems = state.feedItems.filter( - (item) => - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]), - ) - return state +const noBlocksOrMutes = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) + return ( + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted + ) + }) + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), + ) + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - actorService: ActorService - feedService: FeedService - graphService: GraphService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 18d9d3124d0..d8219902b92 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -1,27 +1,22 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' +import { isNotFoundPost } from '../../../../lexicon/types/app/bsky/feed/defs' import { - BlockedPost, - NotFoundPost, - ThreadViewPost, - isNotFoundPost, -} from '../../../../lexicon/types/app/bsky/feed/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPostThread' + QueryParams, + OutputSchema, +} from '../../../../lexicon/types/app/bsky/feed/getPostThread' import AppContext from '../../../../context' +import { ATPROTO_REPO_REV, resHeaders } from '../../../util' import { - FeedService, - FeedRow, - FeedHydrationState, -} from '../../../../services/feed' -import { - getAncestorsAndSelfQb, - getDescendentsQb, -} from '../../../../services/util/post' -import { Database } from '../../../../db' -import { setRepoRev } from '../../../util' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient, isDataplaneError, Code } from '../../../../data-plane' export default function (server: Server, ctx: AppContext) { const getPostThread = createPipeline( @@ -32,301 +27,94 @@ 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 db = ctx.db.getReplica('thread') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - - const [result, repoRev] = await Promise.allSettled([ - getPostThread({ ...params, viewer }, { db, feedService, actorService }), - actorService.getRepoRev(viewer), - ]) - - if (repoRev.status === 'fulfilled') { - setRepoRev(res, repoRev.value) - } - if (result.status === 'rejected') { - throw result.reason + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + + let result: OutputSchema + try { + result = await getPostThread({ ...params, hydrateCtx }, ctx) + } catch (err) { + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) + if (repoRev) { + res.setHeader(ATPROTO_REPO_REV, repoRev) + } + throw err } + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) + return { encoding: 'application/json', - body: result.value, + body: result, + headers: resHeaders({ + repoRev, + labelers: hydrateCtx.labelers, + }), } }, }) } -const skeleton = async (params: Params, ctx: Context) => { - const threadData = await getThreadData(params, ctx) - if (!threadData) { - throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') - } - return { params, threadData } -} - -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { - threadData, - params: { viewer }, - } = state - const relevant = getRelevantIds(threadData) - const hydrated = await feedService.feedHydration({ ...relevant, viewer }) - return { ...state, ...hydrated } -} - -const presentation = (state: HydrationState, ctx: Context) => { - const { params, profiles } = state - const { actorService } = ctx - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, - ) - const thread = composeThread( - state.threadData, - actors, - state, - ctx, - params.viewer, - ) - if (isNotFoundPost(thread)) { - // @TODO technically this could be returned as a NotFoundPost based on lexicon - throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') - } - return { thread } -} - -const composeThread = ( - threadData: PostThread, - actors: ActorInfoMap, - state: HydrationState, - ctx: Context, - viewer: string | null, -) => { - const { feedService } = ctx - const { posts, threadgates, embeds, blocks, labels, lists } = state - - const post = feedService.views.formatPostView( - threadData.post.postUri, - actors, - posts, - threadgates, - embeds, - labels, - lists, - viewer, - ) - - // replies that are invalid due to reply-gating: - // a. may appear as the anchor post, but without any parent or replies. - // b. may not appear anywhere else in the thread. - const isAnchorPost = state.threadData.post.uri === threadData.post.postUri - const info = posts[threadData.post.postUri] - // @TODO re-enable invalidReplyRoot check - // const badReply = !!info?.invalidReplyRoot || !!info?.violatesThreadGate - const badReply = !!info?.violatesThreadGate - const violatesBlock = (post && blocks[post.uri]?.reply) ?? false - const omitBadReply = !isAnchorPost && (badReply || violatesBlock) - - if (!post || omitBadReply) { - return { - $type: 'app.bsky.feed.defs#notFoundPost', - uri: threadData.post.postUri, - notFound: true, - } - } - - if (post.author.viewer?.blocking || post.author.viewer?.blockedBy) { +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + try { + const res = await ctx.dataplane.getThread({ + postUri: params.uri, + above: params.parentHeight, + below: params.depth, + }) return { - $type: 'app.bsky.feed.defs#blockedPost', - uri: threadData.post.postUri, - blocked: true, - author: { - did: post.author.did, - viewer: post.author.viewer - ? { - blockedBy: post.author.viewer?.blockedBy, - blocking: post.author.viewer?.blocking, - } - : undefined, - }, + anchor: params.uri, + uris: res.uris, } - } - - let parent - if (threadData.parent && !badReply && !violatesBlock) { - if (threadData.parent instanceof ParentNotFoundError) { - parent = { - $type: 'app.bsky.feed.defs#notFoundPost', - uri: threadData.parent.uri, - notFound: true, + } catch (err) { + if (isDataplaneError(err, Code.NotFound)) { + return { + anchor: params.uri, + uris: [], } } else { - parent = composeThread(threadData.parent, actors, state, ctx, viewer) - } - } - - let replies: (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined - if (threadData.replies && !badReply) { - replies = threadData.replies.flatMap((reply) => { - const thread = composeThread(reply, actors, state, ctx, viewer) - // e.g. don't bother including #postNotFound reply placeholders for takedowns. either way matches api contract. - const skip = [] - return isNotFoundPost(thread) ? skip : thread - }) - } - - return { - $type: 'app.bsky.feed.defs#threadViewPost', - post, - parent, - replies, - } -} - -const getRelevantIds = ( - thread: PostThread, -): { dids: Set; uris: Set } => { - const dids = new Set() - const uris = new Set() - if (thread.parent && !(thread.parent instanceof ParentNotFoundError)) { - const fromParent = getRelevantIds(thread.parent) - fromParent.dids.forEach((did) => dids.add(did)) - fromParent.uris.forEach((uri) => uris.add(uri)) - } - if (thread.replies) { - for (const reply of thread.replies) { - const fromChild = getRelevantIds(reply) - fromChild.dids.forEach((did) => dids.add(did)) - fromChild.uris.forEach((uri) => uris.add(uri)) + throw err } } - dids.add(thread.post.postAuthorDid) - uris.add(thread.post.postUri) - if (thread.post.replyRoot) { - // ensure root is included for checking interactions - uris.add(thread.post.replyRoot) - dids.add(new AtUri(thread.post.replyRoot).hostname) - } - return { dids, uris } } -const getThreadData = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, feedService } = ctx - const { uri, depth, parentHeight } = params - - const [parents, children] = await Promise.all([ - getAncestorsAndSelfQb(db.db, { uri, parentHeight }) - .selectFrom('ancestor') - .innerJoin( - feedService.selectPostQb().as('post'), - 'post.uri', - 'ancestor.uri', - ) - .selectAll('post') - .execute(), - getDescendentsQb(db.db, { uri, depth }) - .selectFrom('descendent') - .innerJoin( - feedService.selectPostQb().as('post'), - 'post.uri', - 'descendent.uri', - ) - .selectAll('post') - .orderBy('sortAt', 'desc') - .execute(), - ]) - // prevent self-referential loops - const includedPosts = new Set([uri]) - const parentsByUri = parents.reduce((acc, post) => { - return Object.assign(acc, { [post.uri]: post }) - }, {} as Record) - const childrenByParentUri = children.reduce((acc, child) => { - if (!child.replyParent) return acc - if (includedPosts.has(child.uri)) return acc - includedPosts.add(child.uri) - acc[child.replyParent] ??= [] - acc[child.replyParent].push(child) - return acc - }, {} as Record) - const post = parentsByUri[uri] - if (!post) return null - return { - post, - parent: post.replyParent - ? getParentData( - parentsByUri, - includedPosts, - post.replyParent, - parentHeight, - ) - : undefined, - replies: getChildrenData(childrenByParentUri, uri, depth), - } -} - -const getParentData = ( - postsByUri: Record, - includedPosts: Set, - uri: string, - depth: number, -): PostThread | ParentNotFoundError | undefined => { - if (depth < 1) return undefined - if (includedPosts.has(uri)) return undefined - includedPosts.add(uri) - const post = postsByUri[uri] - if (!post) return new ParentNotFoundError(uri) - return { - post, - parent: post.replyParent - ? getParentData(postsByUri, includedPosts, post.replyParent, depth - 1) - : undefined, - replies: [], - } -} - -const getChildrenData = ( - childrenByParentUri: Record, - uri: string, - depth: number, -): PostThread[] | undefined => { - if (depth === 0) return undefined - const children = childrenByParentUri[uri] ?? [] - return children.map((row) => ({ - post: row, - replies: getChildrenData(childrenByParentUri, row.postUri, depth - 1), - })) +const hydration = async ( + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateThreadPosts( + skeleton.uris.map((uri) => ({ uri })), + params.hydrateCtx, + ) } -class ParentNotFoundError extends Error { - constructor(public uri: string) { - super(`Parent not found: ${uri}`) +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, params, skeleton, hydration } = inputs + const thread = ctx.views.thread(skeleton, hydration, { + height: params.parentHeight, + depth: params.depth, + }) + if (isNotFoundPost(thread)) { + // @TODO technically this could be returned as a NotFoundPost based on lexicon + throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') } -} - -type PostThread = { - post: FeedRow - parent?: PostThread | ParentNotFoundError - replies?: PostThread[] + return { thread } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - threadData: PostThread +type Skeleton = { + anchor: string + uris: string[] } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 9db7cf0a252..283639a5606 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,101 +1,86 @@ -import { dedupeStrs } from '@atproto/common' +import { dedupeStrs, mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' import { createPipeline } from '../../../../pipeline' -import { ActorService } from '../../../../services/actor' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { creatorFromUri } from '../../../../views/util' +import { resHeaders } from '../../../util' 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 }) => { - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const results = await getPosts( - { ...params, viewer }, - { db, feedService, actorService }, - ) + const results = await getPosts({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: results, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async (params: Params, ctx: Context) => { - const deduped = dedupeStrs(params.uris) - const feedItems = await ctx.feedService.postUrisToFeedItems(deduped) - return { params, feedItems } +const skeleton = async (inputs: { params: Params }) => { + return { posts: dedupeStrs(inputs.params.uris) } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.hydrateCtx, + ) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true - return !state.bam.block([viewer, item.postAuthorDid]) +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.posts = skeleton.posts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService, actorService } = ctx - const { feedItems, profiles, params } = state - const SKIP = [] - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const posts = mapDefined(skeleton.posts, (uri) => + ctx.views.post(uri, hydration), ) - const postViews = feedItems.flatMap((item) => { - const postView = feedService.views.formatPostView( - item.postUri, - actors, - state.posts, - state.threadgates, - state.embeds, - state.labels, - state.lists, - params.viewer, - ) - return postView ?? SKIP - }) - return { posts: postViews } + return { posts } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + posts: string[] } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index 7d28014a7b7..2e5c8c02b21 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,14 +1,17 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getRepostedBy' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' -import { Database } from '../../../../db' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { Actor } from '../../../../db/tables/actor' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getRepostedBy = createPipeline( @@ -19,104 +22,93 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.feed.getRepostedBy({ auth: ctx.authVerifier.standardOptional, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - - const result = await getRepostedBy( - { ...params, viewer }, - { db, actorService, graphService }, - ) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getRepostedBy({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db } = ctx - const { limit, cursor, uri, cid } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, repostedBy: [] } - } - - let builder = db.db - .selectFrom('repost') - .where('repost.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'repost.creator') - .where(notSoftDeletedClause(ref('creator'))) - .selectAll('creator') - .select(['repost.cid as cid', 'repost.sortAt as sortAt']) - - if (cid) { - builder = builder.where('repost.subjectCid', '=', cid) +const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { reposts: [] } } - - const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, + const res = await ctx.hydrator.dataplane.getRepostsBySubject({ + subject: { uri: params.uri, cid: params.cid }, + cursor: params.cursor, + limit: params.limit, }) - - const repostedBy = await builder.execute() - return { params, repostedBy, cursor: keyset.packFromResult(repostedBy) } + return { + reposts: res.uris, + cursor: parseString(res.cursor), + } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, repostedBy } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles(repostedBy, viewer), - graphService.getBlockAndMuteState( - viewer ? repostedBy.map((item) => [viewer, item.did]) : [], - ), - ]) - return { ...state, bam, actors } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}) => { + const { ctx, params, skeleton } = inputs + return await ctx.hydrator.hydrateReposts(skeleton.reposts, params.hydrateCtx) } -const noBlocks = (state: HydrationState) => { - const { viewer } = state.params - if (!viewer) return state - state.repostedBy = state.repostedBy.filter( - (item) => !state.bam.block([viewer, item.did]), - ) - return state +const noBlocks = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + skeleton.reposts = skeleton.reposts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, repostedBy, actors, cursor } = state - const { uri, cid } = params - const repostedByView = mapDefined(repostedBy, (item) => actors[item.did]) - return { repostedBy: repostedByView, cursor, uri, cid } +const presentation = (inputs: { + ctx: Context + params: Params + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, params, skeleton, hydration } = inputs + const repostViews = mapDefined(skeleton.reposts, (uri) => { + const repost = hydration.reposts?.get(uri) + if (!repost?.record) { + return + } + const creatorDid = creatorFromUri(uri) + return ctx.views.profile(creatorDid, hydration) + }) + return { + repostedBy: repostViews, + cursor: skeleton.cursor, + uri: params.uri, + cid: params.cid, + } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - repostedBy: Actor[] +type Skeleton = { + reposts: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index b96ae2722fa..ad13b3a0a31 100644 --- a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,38 +1,36 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { parseString } from '../../../../hydration/util' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.authVerifier.standardOptional, - handler: async ({ auth }) => { - // @NOTE ignores cursor, doesn't matter for appview swap + handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica() - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const feedsRes = await db.db - .selectFrom('suggested_feed') - .orderBy('suggested_feed.order', 'asc') - .selectAll() - .execute() - const genInfos = await feedService.getFeedGeneratorInfos( - feedsRes.map((r) => r.uri), - viewer, - ) - const genList = feedsRes.map((r) => genInfos[r.uri]).filter(Boolean) - const creators = genList.map((gen) => gen.creator) - const profiles = await actorService.views.profilesBasic(creators, viewer) + const labelers = ctx.reqLabelers(req) - const feedViews = mapDefined(genList, (gen) => - feedService.views.formatFeedGeneratorView(gen, profiles), + // @NOTE no need to coordinate the cursor for appview swap, as v1 doesn't use the cursor + const suggestedRes = await ctx.dataplane.getSuggestedFeeds({ + actorDid: viewer ?? undefined, + limit: params.limit, + cursor: params.cursor, + }) + const uris = suggestedRes.uris + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const hydration = await ctx.hydrator.hydrateFeedGens(uris, hydrateCtx) + const feedViews = mapDefined(uris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) return { encoding: 'application/json', body: { feeds: feedViews, + cursor: parseString(suggestedRes.cursor), }, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 05ef505ea04..8e5dc488c33 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -1,18 +1,18 @@ -import { sql } from 'kysely' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { FeedAlgorithm, FeedKeyset, getFeedDateThreshold } from '../util/feed' -import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { Database } from '../../../../db' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getTimeline' -import { setRepoRev } from '../../../util' -import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' +import { clearlyBadCursor, resHeaders } from '../../../util' import { createPipeline } from '../../../../pipeline' +import { + HydrateCtx, + HydrationState, + Hydrator, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { mapDefined } from '@atproto/common' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getTimeline = createPipeline( @@ -23,202 +23,99 @@ 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 }) => { const viewer = auth.credentials.iss - const db = ctx.db.getReplica('timeline') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const [result, repoRev] = await Promise.all([ - getTimeline({ ...params, viewer }, { db, feedService }), - actorService.getRepoRev(viewer), - ]) + const result = await getTimeline( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, + ) - setRepoRev(res, repoRev) + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers, repoRev }), } }, }) } -export const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { cursor, limit, algorithm, viewer } = params - const { db } = ctx - const { ref } = db.db.dynamic - - if (algorithm && algorithm !== FeedAlgorithm.ReverseChronological) { - throw new InvalidRequestError(`Unsupported algorithm: ${algorithm}`) - } - - if (limit === 1 && !cursor) { - // special case for limit=1, which is often used to check if there are new items at the top of the timeline. - return skeletonLimit1(params, ctx) - } - - if (FeedKeyset.clearlyBad(cursor)) { - return { params, feedItems: [] } +export const skeleton = async (inputs: { + ctx: Context + params: Params +}): Promise => { + const { ctx, params } = inputs + if (clearlyBadCursor(params.cursor)) { + return { items: [] } } - - const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - const sortFrom = keyset.unpack(cursor)?.primary - - let followQb = db.db - .selectFrom('feed_item') - .innerJoin('follow', 'follow.subjectDid', 'feed_item.originatorDid') - .where('follow.creator', '=', viewer) - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - - followQb = paginate(followQb, { - limit, - cursor, - keyset, - tryIndex: true, - }) - - let selfQb = db.db - .selectFrom('feed_item') - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .where('feed_item.originatorDid', '=', viewer) - .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - - selfQb = paginate(selfQb, { - limit: Math.min(limit, 10), - cursor, - keyset, - tryIndex: true, + const res = await ctx.dataplane.getTimeline({ + actorDid: params.hydrateCtx.viewer, + limit: params.limit, + cursor: params.cursor, }) - - const [followRes, selfRes] = await Promise.all([ - followQb.execute(), - selfQb.execute(), - ]) - - const feedItems: FeedRow[] = [...followRes, ...selfRes] - .sort((a, b) => { - if (a.sortAt > b.sortAt) return -1 - if (a.sortAt < b.sortAt) return 1 - return a.cid > b.cid ? -1 : 1 - }) - .slice(0, limit) - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), + cursor: parseString(res.cursor), } } -// The limit=1 case is used commonly to check if there are new items at the top of the timeline. -// Since it's so common, it's optimized here. The most common strategy that postgres takes to -// build a timeline is to grab all recent content from each of the user's follow, then paginate it. -// The downside here is that it requires grabbing all recent content from all follows, even if you -// only want a single result. The approach here instead takes the single most recent post from -// each of the user's follows, then sorts only those and takes the top item. -const skeletonLimit1 = async (params: Params, ctx: Context) => { - const { viewer } = params - const { db } = ctx - const { ref } = db.db.dynamic - const creatorsQb = db.db - .selectFrom('follow') - .where('creator', '=', viewer) - .select('subjectDid as did') - .unionAll(sql`select ${viewer} as did`) - const feedItemsQb = db.db - .selectFrom(creatorsQb.as('creator')) - .innerJoinLateral( - (eb) => { - const keyset = new FeedKeyset( - ref('feed_item.sortAt'), - ref('feed_item.cid'), - ) - const creatorFeedItemQb = eb - .selectFrom('feed_item') - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .whereRef('feed_item.originatorDid', '=', 'creator.did') - .where('feed_item.sortAt', '>', getFeedDateThreshold(undefined, 2)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) - return paginate(creatorFeedItemQb, { limit: 1, keyset }).as('result') - }, - (join) => join.onTrue(), - ) - .selectAll('result') - const keyset = new FeedKeyset(ref('result.sortAt'), ref('result.cid')) - const feedItems = await paginate(feedItemsQb, { limit: 1, keyset }).execute() - return { - params, - feedItems, - cursor: keyset.packFromResult(feedItems), - } +const hydration = async (inputs: { + ctx: Context + params: Params + skeleton: Skeleton +}): Promise => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.hydrateCtx) } -const hydration = async ( - state: SkeletonState, - ctx: Context, -): Promise => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, +const noBlocksOrMutes = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}): Skeleton => { + const { ctx, skeleton, hydration } = inputs + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) + return ( + !bam.authorBlocked && + !bam.authorMuted && + !bam.originatorBlocked && + !bam.originatorMuted + ) }) - return { ...state, ...hydrated } + return skeleton } -const noBlocksOrMutes = (state: HydrationState): HydrationState => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter( - (item) => - !state.bam.block([viewer, item.postAuthorDid]) && - !state.bam.block([viewer, item.originatorDid]) && - !state.bam.mute([viewer, item.postAuthorDid]) && - !state.bam.mute([viewer, item.originatorDid]), +const presentation = (inputs: { + ctx: Context + skeleton: Skeleton + hydration: HydrationState +}) => { + const { ctx, skeleton, hydration } = inputs + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) - return state -} - -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, params.viewer) - return { feed, cursor } + return { feed, cursor: skeleton.cursor } } type Context = { - db: Database - feedService: FeedService + hydrator: Hydrator + views: Views + dataplane: DataPlaneClient } -type Params = QueryParams & { viewer: string } +type Params = QueryParams & { hydrateCtx: HydrateCtx & { viewer: string } } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + items: FeedItem[] cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 3ac0b3f9477..ac7268dbba7 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -1,17 +1,21 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import { InvalidRequestError } from '@atproto/xrpc-server' import AtpAgent from '@atproto/api' import { mapDefined } from '@atproto/common' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/searchPosts' -import { Database } from '../../../../db' import { - FeedHydrationState, - FeedRow, - FeedService, -} from '../../../../services/feed' -import { ActorService } from '../../../../services/actor' -import { createPipeline } from '../../../../pipeline' + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { DataPlaneClient } from '../../../../data-plane' +import { parseString } from '../../../../hydration/util' +import { creatorFromUri } from '../../../../views/util' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const searchPosts = createPipeline( @@ -22,110 +26,92 @@ 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 db = ctx.db.getReplica('search') - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - const searchAgent = ctx.searchAgent - if (!searchAgent) { - throw new InvalidRequestError('Search not available') - } - - const results = await searchPosts( - { ...params, viewer }, - { db, feedService, actorService, searchAgent }, - ) - + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const results = await searchPosts({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: results, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - // @NOTE cursors wont change on appview swap - const res = await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton({ - q: params.q, - cursor: params.cursor, +const skeleton = async (inputs: SkeletonFnInput) => { + const { ctx, params } = inputs + + if (ctx.searchAgent) { + // @NOTE cursors wont change on appview swap + const { data: res } = + await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton({ + q: params.q, + cursor: params.cursor, + limit: params.limit, + }) + return { + posts: res.posts.map(({ uri }) => uri), + cursor: parseString(res.cursor), + } + } + + const res = await ctx.dataplane.searchPosts({ + term: params.q, limit: params.limit, + cursor: params.cursor, }) - const postUris = res.data.posts.map((a) => a.uri) - const feedItems = await ctx.feedService.postUrisToFeedItems(postUris) return { - params, - feedItems, - cursor: res.data.cursor, - hitsTotal: res.data.hitsTotal, + posts: res.uris, + cursor: parseString(res.cursor), } } const hydration = async ( - state: SkeletonState, - ctx: Context, -): Promise => { - const { feedService } = ctx - const { params, feedItems } = state - const refs = feedService.feedItemRefs(feedItems) - const hydrated = await feedService.feedHydration({ - ...refs, - viewer: params.viewer, - }) - return { ...state, ...hydrated } + inputs: HydrationFnInput, +) => { + const { ctx, params, skeleton } = inputs + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.hydrateCtx, + ) } -const noBlocks = (state: HydrationState): HydrationState => { - const { viewer } = state.params - state.feedItems = state.feedItems.filter((item) => { - if (!viewer) return true - return !state.bam.block([viewer, item.postAuthorDid]) +const noBlocks = (inputs: RulesFnInput) => { + const { ctx, skeleton, hydration } = inputs + skeleton.posts = skeleton.posts.filter((uri) => { + const creator = creatorFromUri(uri) + return !ctx.views.viewerBlockExists(creator, hydration) }) - return state + return skeleton } -const presentation = (state: HydrationState, ctx: Context) => { - const { feedService, actorService } = ctx - const { feedItems, profiles, params } = state - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - params.viewer, - ) - - const postViews = mapDefined(feedItems, (item) => - feedService.views.formatPostView( - item.postUri, - actors, - state.posts, - state.threadgates, - state.embeds, - state.labels, - state.lists, - params.viewer, - ), +const presentation = ( + inputs: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = inputs + const posts = mapDefined(skeleton.posts, (uri) => + ctx.views.post(uri, hydration), ) - return { posts: postViews, cursor: state.cursor, hitsTotal: state.hitsTotal } + return { + posts, + cursor: skeleton.cursor, + hitsTotal: skeleton.hitsTotal, + } } type Context = { - db: Database - feedService: FeedService - actorService: ActorService - searchAgent: AtpAgent + dataplane: DataPlaneClient + hydrator: Hydrator + views: Views + searchAgent?: AtpAgent } -type Params = QueryParams & { viewer: string | null } +type Params = QueryParams & { hydrateCtx: HydrateCtx } -type SkeletonState = { - params: Params - feedItems: FeedRow[] +type Skeleton = { + posts: string[] hitsTotal?: number cursor?: string } - -type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index adc28752a25..3042b07ed13 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -1,54 +1,88 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getBlocks' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' 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 }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { blocks: [] }, - } + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getBlocks( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, + ) + return { + encoding: 'application/json', + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + if (clearlyBadCursor(params.cursor)) { + return { blockedDids: [] } + } + const { blockUris, cursor } = await ctx.hydrator.dataplane.getBlocks({ + actorDid: params.hydrateCtx.viewer, + cursor: params.cursor, + limit: params.limit, + }) + const blocks = await ctx.hydrator.graph.getBlocks(blockUris) + const blockedDids = mapDefined( + blockUris, + (uri) => blocks.get(uri)?.record.subject, + ) + return { + blockedDids, + cursor: cursor || undefined, + } +} - let blocksReq = db.db - .selectFrom('actor_block') - .where('actor_block.creator', '=', requester) - .innerJoin('actor as subject', 'subject.did', 'actor_block.subjectDid') - .where(notSoftDeletedClause(ref('subject'))) - .selectAll('subject') - .select(['actor_block.cid as cid', 'actor_block.sortAt as sortAt']) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + return ctx.hydrator.hydrateProfiles(skeleton.blockedDids, params.hydrateCtx) +} - const keyset = new TimeCidKeyset( - ref('actor_block.sortAt'), - ref('actor_block.cid'), - ) - blocksReq = paginate(blocksReq, { - limit, - cursor, - keyset, - }) +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { blockedDids, cursor } = skeleton + const blocks = mapDefined(blockedDids, (did) => { + return ctx.views.profile(did, hydration) + }) + return { blocks, cursor } +} - const blocksRes = await blocksReq.execute() +type Context = { + hydrator: Hydrator + views: Views +} - const actorService = ctx.services.actor(db) - const blocks = await actorService.views.profilesList(blocksRes, requester) +type Params = QueryParams & { + hydrateCtx: HydrateCtx & { viewer: string } +} - return { - encoding: 'application/json', - body: { - blocks, - cursor: keyset.packFromResult(blocksRes), - }, - } - }, - }) +type SkeletonState = { + blockedDids: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index bf22b2be6cb..0e9a008df78 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -3,146 +3,144 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { createPipeline } from '../../../../pipeline' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { didFromUri } from '../../../../hydration/util' +import { + HydrateCtx, + Hydrator, + mergeStates, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getFollowers = createPipeline( skeleton, hydration, - noBlocksInclInvalid, + noBlocks, presentation, ) server.app.bsky.graph.getFollowers({ auth: ctx.authVerifier.optionalStandardOrRole, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) + handler: async ({ params, auth, req }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + includeTakedowns, + }) - const result = await getFollowers( - { ...params, viewer, canViewTakedowns }, - { db, actorService, graphService }, - ) + const result = await getFollowers({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService } = ctx - const { limit, cursor, actor, canViewTakedowns } = params - const { ref } = db.db.dynamic - - const subject = await actorService.getActor(actor, canViewTakedowns) - if (!subject) { - throw new InvalidRequestError(`Actor not found: ${actor}`) +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) + if (!subjectDid) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, followers: [], subject } + if (clearlyBadCursor(params.cursor)) { + return { subjectDid, followUris: [] } } - - let followersReq = db.db - .selectFrom('follow') - .where('follow.subjectDid', '=', subject.did) - .innerJoin('actor as creator', 'creator.did', 'follow.creator') - .if(!canViewTakedowns, (qb) => - qb.where(notSoftDeletedClause(ref('creator'))), - ) - .selectAll('creator') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followersReq = paginate(followersReq, { - limit, - cursor, - keyset, + const { followers, cursor } = await ctx.hydrator.graph.getActorFollowers({ + did: subjectDid, + cursor: params.cursor, + limit: params.limit, }) - - const followers = await followersReq.execute() return { - params, - followers, - subject, - cursor: keyset.packFromResult(followers), + subjectDid, + followUris: followers.map((f) => f.uri), + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, followers, subject } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles([subject, ...followers], viewer), - graphService.getBlockAndMuteState( - followers.flatMap((item) => { - if (viewer) { - return [ - [viewer, item.did], - [subject.did, item.did], - ] - } - return [[subject.did, item.did]] - }), - ), - ]) - return { ...state, bam, actors } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { followUris, subjectDid } = skeleton + const followState = await ctx.hydrator.hydrateFollows(followUris) + const dids = [subjectDid] + if (followState.follows) { + for (const [uri, follow] of followState.follows) { + if (follow) { + dids.push(didFromUri(uri)) + } + } + } + const profileState = await ctx.hydrator.hydrateProfiles( + dids, + params.hydrateCtx, + ) + return mergeStates(followState, profileState) } -const noBlocksInclInvalid = (state: HydrationState) => { - const { subject } = state - const { viewer } = state.params - state.followers = state.followers.filter( - (item) => - !state.bam.block([subject.did, item.did]) && - (!viewer || !state.bam.block([viewer, item.did])), - ) - return state +const noBlocks = (input: RulesFnInput) => { + const { skeleton, params, hydration, ctx } = input + const viewer = params.hydrateCtx.viewer + skeleton.followUris = skeleton.followUris.filter((followUri) => { + const followerDid = didFromUri(followUri) + return ( + !hydration.followBlocks?.get(followUri) && + (!viewer || !ctx.views.viewerBlockExists(followerDid, hydration)) + ) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, followers, subject, actors, cursor } = state - const subjectView = actors[subject.did] - const followersView = mapDefined(followers, (item) => actors[item.did]) - if (!subjectView) { +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton, params } = input + const { subjectDid, followUris, cursor } = skeleton + const isTakendown = (did: string) => + ctx.views.actorIsTakendown(did, hydration) + + const subject = ctx.views.profile(subjectDid, hydration) + if ( + !subject || + (!params.hydrateCtx.includeTakedowns && isTakendown(subjectDid)) + ) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - return { followers: followersView, subject: subjectView, cursor } + + const followers = mapDefined(followUris, (followUri) => { + const followerDid = didFromUri(followUri) + if (!params.hydrateCtx.includeTakedowns && isTakendown(followerDid)) { + return + } + return ctx.views.profile(didFromUri(followUri), hydration) + }) + + return { followers, subject, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string | null - canViewTakedowns: boolean + hydrateCtx: HydrateCtx } type SkeletonState = { - params: Params - followers: Actor[] - subject: Actor + subjectDid: string + followUris: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 8d294b27354..94bba1c2d2b 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -1,149 +1,144 @@ import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollows' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { createPipeline } from '../../../../pipeline' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { + HydrateCtx, + Hydrator, + mergeStates, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { - const getFollows = createPipeline( - skeleton, - hydration, - noBlocksInclInvalid, - presentation, - ) + const getFollows = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.graph.getFollows({ auth: ctx.authVerifier.optionalStandardOrRole, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) - const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth) - - const result = await getFollows( - { ...params, viewer, canViewTakedowns }, - { db, actorService, graphService }, - ) + handler: async ({ params, auth, req }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + includeTakedowns, + }) + + // @TODO ensure canViewTakedowns gets threaded through and applied properly + const result = await getFollows({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -const skeleton = async ( - params: Params, - ctx: Context, -): Promise => { - const { db, actorService } = ctx - const { limit, cursor, actor, canViewTakedowns } = params - const { ref } = db.db.dynamic - - const creator = await actorService.getActor(actor, canViewTakedowns) - if (!creator) { - throw new InvalidRequestError(`Actor not found: ${actor}`) +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) + if (!subjectDid) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, follows: [], creator } + if (clearlyBadCursor(params.cursor)) { + return { subjectDid, followUris: [] } } - - let followsReq = db.db - .selectFrom('follow') - .where('follow.creator', '=', creator.did) - .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') - .if(!canViewTakedowns, (qb) => - qb.where(notSoftDeletedClause(ref('subject'))), - ) - .selectAll('subject') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followsReq = paginate(followsReq, { - limit, - cursor, - keyset, + const { follows, cursor } = await ctx.hydrator.graph.getActorFollows({ + did: subjectDid, + cursor: params.cursor, + limit: params.limit, }) - - const follows = await followsReq.execute() - return { - params, - follows, - creator, - cursor: keyset.packFromResult(follows), + subjectDid, + followUris: follows.map((f) => f.uri), + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService } = ctx - const { params, follows, creator } = state - const { viewer } = params - const [actors, bam] = await Promise.all([ - actorService.views.profiles([creator, ...follows], viewer), - graphService.getBlockAndMuteState( - follows.flatMap((item) => { - if (viewer) { - return [ - [viewer, item.did], - [creator.did, item.did], - ] - } - return [[creator.did, item.did]] - }), - ), - ]) - return { ...state, bam, actors } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { followUris, subjectDid } = skeleton + const followState = await ctx.hydrator.hydrateFollows(followUris) + const dids = [subjectDid] + if (followState.follows) { + for (const follow of followState.follows.values()) { + if (follow) { + dids.push(follow.record.subject) + } + } + } + const profileState = await ctx.hydrator.hydrateProfiles( + dids, + params.hydrateCtx, + ) + return mergeStates(followState, profileState) } -const noBlocksInclInvalid = (state: HydrationState) => { - const { creator } = state - const { viewer } = state.params - state.follows = state.follows.filter( - (item) => - !state.bam.block([creator.did, item.did]) && - (!viewer || !state.bam.block([viewer, item.did])), - ) - return state +const noBlocks = (input: RulesFnInput) => { + const { skeleton, params, hydration, ctx } = input + const viewer = params.hydrateCtx.viewer + skeleton.followUris = skeleton.followUris.filter((followUri) => { + const follow = hydration.follows?.get(followUri) + if (!follow) return false + return ( + !hydration.followBlocks?.get(followUri) && + (!viewer || + !ctx.views.viewerBlockExists(follow.record.subject, hydration)) + ) + }) + return skeleton } -const presentation = (state: HydrationState) => { - const { params, follows, creator, actors, cursor } = state - const creatorView = actors[creator.did] - const followsView = mapDefined(follows, (item) => actors[item.did]) - if (!creatorView) { +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton, params } = input + const { subjectDid, followUris, cursor } = skeleton + const isTakendown = (did: string) => + ctx.views.actorIsTakendown(did, hydration) + + const subject = ctx.views.profile(subjectDid, hydration) + if ( + !subject || + (!params.hydrateCtx.includeTakedowns && isTakendown(subjectDid)) + ) { throw new InvalidRequestError(`Actor not found: ${params.actor}`) } - return { follows: followsView, subject: creatorView, cursor } + + const follows = mapDefined(followUris, (followUri) => { + const followDid = hydration.follows?.get(followUri)?.record.subject + if (!followDid) return + if (!params.hydrateCtx.includeTakedowns && isTakendown(followDid)) { + return + } + return ctx.views.profile(followDid, hydration) + }) + + return { follows, subject, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string | null - canViewTakedowns: boolean + hydrateCtx: HydrateCtx } type SkeletonState = { - params: Params - follows: Actor[] - creator: Actor + subjectDid: string + followUris: string[] cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap -} diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 82007b45388..b0e51642332 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -3,131 +3,102 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getList' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { Actor } from '../../../../db/tables/actor' -import { GraphService, ListInfo } from '../../../../services/graph' -import { ActorService, ProfileHydrationState } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { + HydrateCtx, + Hydrator, + mergeStates, +} from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' +import { ListItemInfo } from '../../../../proto/bsky_pb' 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 }) => { - const db = ctx.db.getReplica() - const graphService = ctx.services.graph(db) - const actorService = ctx.services.actor(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - - const result = await getList( - { ...params, viewer }, - { db, graphService, actorService }, - ) - + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getList({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db, graphService } = ctx - const { list, limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - const listRes = await graphService - .getListsQb(viewer) - .where('list.uri', '=', list) - .executeTakeFirst() - if (!listRes) { - throw new InvalidRequestError(`List not found: ${list}`) - } - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, list: listRes, listItems: [] } + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUri: params.list, listitems: [] } } - - let itemsReq = graphService - .getListItemsQb() - .where('list_item.listUri', '=', list) - .where('list_item.creator', '=', listRes.creator) - - const keyset = new TimeCidKeyset( - ref('list_item.sortAt'), - ref('list_item.cid'), - ) - - itemsReq = paginate(itemsReq, { - limit, - cursor, - keyset, + const { listitems, cursor } = await ctx.hydrator.dataplane.getListMembers({ + listUri: params.list, + limit: params.limit, + cursor: params.cursor, }) - - const listItems = await itemsReq.execute() - return { - params, - list: listRes, - listItems, - cursor: keyset.packFromResult(listItems), + listUri: params.list, + listitems, + cursor: cursor || undefined, } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, list, listItems } = state - const profileState = await actorService.views.profileHydration( - [list, ...listItems].map((x) => x.did), - { viewer: params.viewer }, - ) - return { ...state, ...profileState } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { listUri, listitems } = skeleton + const [listState, profileState] = await Promise.all([ + ctx.hydrator.hydrateLists([listUri], params.hydrateCtx), + ctx.hydrator.hydrateProfiles( + listitems.map(({ did }) => did), + params.hydrateCtx, + ), + ]) + return mergeStates(listState, profileState) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService, graphService } = ctx - const { params, list, listItems, cursor, ...profileState } = state - const actors = actorService.views.profilePresentation( - Object.keys(profileState.profiles), - profileState, - params.viewer, - ) - const creator = actors[list.creator] - if (!creator) { - throw new InvalidRequestError(`Actor not found: ${list.handle}`) - } - const listView = graphService.formatListView(list, actors) - if (!listView) { - throw new InvalidRequestError('List not found') - } - const items = mapDefined(listItems, (item) => { - const subject = actors[item.did] +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUri, listitems, cursor } = skeleton + const list = ctx.views.list(listUri, hydration) + const items = mapDefined(listitems, ({ uri, did }) => { + const subject = ctx.views.profile(did, hydration) if (!subject) return - return { uri: item.uri, subject } + return { uri, subject } }) - return { list: listView, items, cursor } + if (!list) { + throw new InvalidRequestError('List not found') + } + return { list, items, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string | null + hydrateCtx: HydrateCtx } type SkeletonState = { - params: Params - list: Actor & ListInfo - listItems: (Actor & { uri: string; cid: string; sortAt: string })[] + listUri: string + listitems: ListItemInfo[] cursor?: string } - -type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index 6fb6df55e82..89f79edbb8f 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -1,13 +1,17 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { GraphService, ListInfo } from '../../../../services/graph' -import { ActorService, ProfileHydrationState } from '../../../../services/actor' -import { createPipeline, noRules } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getListBlocks = createPipeline( @@ -18,102 +22,65 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.graph.getListBlocks({ auth: ctx.authVerifier.standard, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const graphService = ctx.services.graph(db) - const actorService = ctx.services.actor(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) const result = await getListBlocks( - { ...params, viewer }, - { db, actorService, graphService }, + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, ) - return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db, graphService } = ctx - const { limit, cursor, viewer } = params - const { ref } = db.db.dynamic - - if (TimeCidKeyset.clearlyBad(cursor)) { - return { params, listInfos: [] } - } - - let listsReq = graphService - .getListsQb(viewer) - .whereExists( - db.db - .selectFrom('list_block') - .where('list_block.creator', '=', viewer) - .whereRef('list_block.subjectUri', '=', ref('list.uri')) - .selectAll(), - ) - - const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) - - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - - const listInfos = await listsReq.execute() - - return { - params, - listInfos, - cursor: keyset.packFromResult(listInfos), + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } } + const { listUris, cursor } = + await ctx.hydrator.dataplane.getBlocklistSubscriptions({ + actorDid: params.hydrateCtx.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService } = ctx - const { params, listInfos } = state - const profileState = await actorService.views.profileHydration( - listInfos.map((list) => list.creator), - { viewer: params.viewer }, - ) - return { ...state, ...profileState } +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + return await ctx.hydrator.hydrateLists(skeleton.listUris, params.hydrateCtx) } -const presentation = (state: HydrationState, ctx: Context) => { - const { actorService, graphService } = ctx - const { params, listInfos, cursor, ...profileState } = state - const actors = actorService.views.profilePresentation( - Object.keys(profileState.profiles), - profileState, - params.viewer, - ) - const lists = mapDefined(listInfos, (list) => - graphService.formatListView(list, actors), - ) +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => ctx.views.list(uri, hydration)) return { lists, cursor } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string + hydrateCtx: HydrateCtx & { viewer: string } } type SkeletonState = { - params: Params - listInfos: (Actor & ListInfo)[] + listUris: string[] cursor?: string } - -type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts index 8baa509ae47..fbd62c215c7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts @@ -1,58 +1,86 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' import AppContext from '../../../../context' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getListMutes = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.graph.getListMutes({ auth: ctx.authVerifier.standard, - handler: async ({ params, auth }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { lists: [] }, - } + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getListMutes( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, + ) + return { + encoding: 'application/json', + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - - const graphService = ctx.services.graph(db) +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } + } + const { listUris, cursor } = + await ctx.hydrator.dataplane.getMutelistSubscriptions({ + actorDid: params.hydrateCtx.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } +} - let listsReq = graphService - .getListsQb(requester) - .whereExists( - db.db - .selectFrom('list_mute') - .where('list_mute.mutedByDid', '=', requester) - .whereRef('list_mute.listUri', '=', ref('list.uri')) - .selectAll(), - ) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + return await ctx.hydrator.hydrateLists(skeleton.listUris, params.hydrateCtx) +} - const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - const listsRes = await listsReq.execute() +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => ctx.views.list(uri, hydration)) + return { lists, cursor } +} - const actorService = ctx.services.actor(db) - const profiles = await actorService.views.profiles(listsRes, requester) +type Context = { + hydrator: Hydrator + views: Views +} - const lists = mapDefined(listsRes, (row) => - graphService.formatListView(row, profiles), - ) +type Params = QueryParams & { + hydrateCtx: HydrateCtx & { viewer: string } +} - return { - encoding: 'application/json', - body: { - lists, - cursor: keyset.packFromResult(listsRes), - }, - } - }, - }) +type SkeletonState = { + listUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index 40bb903f5b4..0f239bccf4b 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -1,63 +1,81 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getLists' import AppContext from '../../../../context' +import { + createPipeline, + HydrationFnInput, + noRules, + PresentationFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { clearlyBadCursor, resHeaders } from '../../../util' 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 }) => { - const { actor, limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { lists: [] }, - } + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getLists({ ...params, hydrateCtx }, ctx) + + return { + encoding: 'application/json', + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } + }, + }) +} - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { ctx, params } = input + if (clearlyBadCursor(params.cursor)) { + return { listUris: [] } + } + const { listUris, cursor } = await ctx.hydrator.dataplane.getActorLists({ + actorDid: params.actor, + cursor: params.cursor, + limit: params.limit, + }) + return { listUris, cursor: cursor || undefined } +} - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { listUris } = skeleton + return ctx.hydrator.hydrateLists(listUris, params.hydrateCtx) +} - const creatorRes = await actorService.getActor(actor) - if (!creatorRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + const { listUris, cursor } = skeleton + const lists = mapDefined(listUris, (uri) => { + return ctx.views.list(uri, hydration) + }) + return { lists, cursor } +} - let listsReq = graphService - .getListsQb(requester) - .where('list.creator', '=', creatorRes.did) - - const keyset = new TimeCidKeyset(ref('list.sortAt'), ref('list.cid')) - listsReq = paginate(listsReq, { - limit, - cursor, - keyset, - }) - - const [listsRes, profiles] = await Promise.all([ - listsReq.execute(), - actorService.views.profiles([creatorRes], requester), - ]) - if (!profiles[creatorRes.did]) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } +type Context = { + hydrator: Hydrator + views: Views +} - const lists = mapDefined(listsRes, (row) => - graphService.formatListView(row, profiles), - ) +type Params = QueryParams & { + hydrateCtx: HydrateCtx +} - return { - encoding: 'application/json', - body: { - lists, - cursor: keyset.packFromResult(listsRes), - }, - } - }, - }) +type SkeletonState = { + listUris: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index 827573258bd..1f417c32b39 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -1,62 +1,84 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getMutes' import AppContext from '../../../../context' -import { notSoftDeletedClause } from '../../../../db/util' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { clearlyBadCursor, resHeaders } from '../../../util' 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 }) => { - const { limit, cursor } = params - const requester = auth.credentials.iss - if (TimeCidKeyset.clearlyBad(cursor)) { - return { - encoding: 'application/json', - body: { mutes: [] }, - } - } - - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - - let mutesReq = db.db - .selectFrom('mute') - .innerJoin('actor', 'actor.did', 'mute.subjectDid') - .where(notSoftDeletedClause(ref('actor'))) - .where('mute.mutedByDid', '=', requester) - .selectAll('actor') - .select('mute.createdAt as createdAt') - - const keyset = new CreatedAtDidKeyset( - ref('mute.createdAt'), - ref('mute.subjectDid'), + handler: async ({ params, auth, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getMutes( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, ) - mutesReq = paginate(mutesReq, { - limit, - cursor, - keyset, - }) - - const mutesRes = await mutesReq.execute() - - const actorService = ctx.services.actor(db) - return { encoding: 'application/json', - body: { - cursor: keyset.packFromResult(mutesRes), - mutes: await actorService.views.profilesList(mutesRes, requester), - }, + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -export class CreatedAtDidKeyset extends TimeCidKeyset<{ - createdAt: string - did: string // dids are treated identically to cids in TimeCidKeyset -}> { - labelResult(result: { createdAt: string; did: string }) { - return { primary: result.createdAt, secondary: result.did } +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + if (clearlyBadCursor(params.cursor)) { + return { mutedDids: [] } } + const { dids, cursor } = await ctx.hydrator.dataplane.getMutes({ + actorDid: params.hydrateCtx.viewer, + cursor: params.cursor, + limit: params.limit, + }) + return { + mutedDids: dids, + cursor: cursor || undefined, + } +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { mutedDids } = skeleton + return ctx.hydrator.hydrateProfiles(mutedDids, params.hydrateCtx) +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { mutedDids, cursor } = skeleton + const mutes = mapDefined(mutedDids, (did) => { + return ctx.views.profile(did, hydration) + }) + return { mutes, cursor } +} + +type Context = { + hydrator: Hydrator + views: Views +} + +type Params = QueryParams & { + hydrateCtx: HydrateCtx & { viewer: string } +} + +type SkeletonState = { + mutedDids: string[] + cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts index 5ac4f3107c4..47aaa6cd083 100644 --- a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts +++ b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Relationship } from '../../../../lexicon/types/app/bsky/graph/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getRelationships({ @@ -15,42 +14,18 @@ export default function (server: Server, ctx: AppContext) { }, } } - const db = ctx.db.getPrimary() - const { ref } = db.db.dynamic - const res = await db.db - .selectFrom('actor') - .select([ - 'actor.did', - db.db - .selectFrom('follow') - .where('creator', '=', actor) - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('following'), - db.db - .selectFrom('follow') - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', actor) - .select('uri') - .as('followedBy'), - ]) - .where('actor.did', 'in', others) - .execute() - - const relationshipsMap = res.reduce((acc, cur) => { - return acc.set(cur.did, { - did: cur.did, - following: cur.following ?? undefined, - followedBy: cur.followedBy ?? undefined, - }) - }, new Map()) - + const res = await ctx.hydrator.actor.getProfileViewerStatesNaive( + others, + actor, + ) const relationships = others.map((did) => { - const relationship = relationshipsMap.get(did) - return relationship + const subject = res.get(did) + return subject ? { $type: 'app.bsky.graph.defs#relationship', - ...relationship, + did, + following: subject.following, + followedBy: subject.followedBy, } : { $type: 'app.bsky.graph.defs#notFoundActor', @@ -58,7 +33,6 @@ export default function (server: Server, ctx: AppContext) { notFound: true, } }) - return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 3aec8ded48e..22e0d588356 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,139 +1,101 @@ -import { sql } from 'kysely' +import { mapDefined } from '@atproto/common' +import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getSuggestedFollowsByActor' import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { Database } from '../../../../db' -import { ActorService } from '../../../../services/actor' - -const RESULT_LENGTH = 10 +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { + const getSuggestedFollowsByActor = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) server.app.bsky.graph.getSuggestedFollowsByActor({ auth: ctx.authVerifier.standard, - handler: async ({ auth, params }) => { - const { actor } = params + handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss - - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const actorDid = await actorService.getActorDid(actor) - - if (!actorDid) { - throw new InvalidRequestError('Actor not found') - } - - const skeleton = await getSkeleton( - { - actor: actorDid, - viewer, - }, - { - db, - actorService, - }, - ) - const hydrationState = await actorService.views.profileDetailHydration( - skeleton.map((a) => a.did), - { viewer }, - ) - const presentationState = actorService.views.profileDetailPresentation( - skeleton.map((a) => a.did), - hydrationState, - { viewer }, + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const result = await getSuggestedFollowsByActor( + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, ) - const suggestions = Object.values(presentationState).filter((profile) => { - return ( - !profile.viewer?.muted && - !profile.viewer?.mutedByList && - !profile.viewer?.blocking && - !profile.viewer?.blockedBy - ) - }) - return { encoding: 'application/json', - body: { suggestions }, + body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } -async function getSkeleton( - params: { - actor: string - viewer: string - }, - ctx: { - db: Database - actorService: ActorService - }, -): Promise<{ did: string }[]> { - const actorsViewerFollows = ctx.db.db - .selectFrom('follow') - .where('creator', '=', params.viewer) - .select('subjectDid') - const mostLikedAccounts = await ctx.db.db - .selectFrom( - ctx.db.db - .selectFrom('like') - .where('creator', '=', params.actor) - .select(sql`split_part(subject, '/', 3)`.as('subjectDid')) - .orderBy('sortAt', 'desc') - .limit(1000) // limit to 1000 - .as('likes'), - ) - .select('likes.subjectDid as did') - .select((qb) => qb.fn.count('likes.subjectDid').as('count')) - .where('likes.subjectDid', 'not in', actorsViewerFollows) - .where('likes.subjectDid', 'not in', [params.actor, params.viewer]) - .groupBy('likes.subjectDid') - .orderBy('count', 'desc') - .limit(RESULT_LENGTH) - .execute() - const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as { - did: string - }[] +const skeleton = async (input: SkeletonFnInput) => { + const { params, ctx } = input + const [relativeToDid] = await ctx.hydrator.actor.getDids([params.actor]) + if (!relativeToDid) { + throw new InvalidRequestError('Actor not found') + } + const { dids, cursor } = await ctx.hydrator.dataplane.getFollowSuggestions({ + actorDid: params.hydrateCtx.viewer, + relativeToDid, + }) + return { + suggestedDids: dids, + cursor: cursor || undefined, + } +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const { suggestedDids } = skeleton + return ctx.hydrator.hydrateProfilesDetailed(suggestedDids, params.hydrateCtx) +} - if (resultDids.length < RESULT_LENGTH) { - // backfill with popular accounts followed by actor - const mostPopularAccountsActorFollows = await ctx.db.db - .selectFrom('follow') - .innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did') - .select('follow.subjectDid as did') - .where('follow.creator', '=', params.actor) - .where('follow.subjectDid', '!=', params.viewer) - .where('follow.subjectDid', 'not in', actorsViewerFollows) - .if(resultDids.length > 0, (qb) => - qb.where( - 'subjectDid', - 'not in', - resultDids.map((a) => a.did), - ), - ) - .orderBy('profile_agg.followersCount', 'desc') - .limit(RESULT_LENGTH) - .execute() +const noBlocksOrMutes = ( + input: RulesFnInput, +) => { + const { ctx, skeleton, hydration } = input + skeleton.suggestedDids = skeleton.suggestedDids.filter( + (did) => + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration), + ) + return skeleton +} - resultDids.push(...mostPopularAccountsActorFollows) - } +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, hydration, skeleton } = input + const { suggestedDids } = skeleton + const suggestions = mapDefined(suggestedDids, (did) => + ctx.views.profileDetailed(did, hydration), + ) + return { suggestions } +} - if (resultDids.length < RESULT_LENGTH) { - // backfill with suggested_follow table - const additional = await ctx.db.db - .selectFrom('suggested_follow') - .where( - 'did', - 'not in', - // exclude any we already have - resultDids.map((a) => a.did).concat([params.actor, params.viewer]), - ) - // and aren't already followed by viewer - .where('did', 'not in', actorsViewerFollows) - .selectAll() - .execute() +type Context = { + hydrator: Hydrator + views: Views +} - resultDids.push(...additional) - } +type Params = QueryParams & { + hydrateCtx: HydrateCtx & { viewer: string } +} - return resultDids +type SkeletonState = { + suggestedDids: string[] } diff --git a/packages/bsky/src/api/app/bsky/graph/muteActor.ts b/packages/bsky/src/api/app/bsky/graph/muteActor.ts index 72a1635c55d..051e5564b9e 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActor.ts @@ -1,54 +1,21 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' 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 db = ctx.db.getPrimary() - - const subjectDid = await ctx.services.actor(db).getActorDid(actor) - if (!subjectDid) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - if (subjectDid === requester) { - throw new InvalidRequestError('Cannot mute oneself') - } - - const muteActor = async () => { - await ctx.services.graph(db).muteActor({ - subjectDid, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.ADD, - actorDid: requester, - subject: subjectDid, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await muteActor() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + const [did] = await ctx.hydrator.actor.getDids([actor]) + if (!did) throw new InvalidRequestError('Actor not found') + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.ADD, + actorDid: requester, + subject: did, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts index f2ee8bfeea0..2f9f8c7573f 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts @@ -1,55 +1,18 @@ -import assert from 'node:assert' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import * as lex from '../../../../lexicon/lexicons' import AppContext from '../../../../context' -import { AtUri } from '@atproto/syntax' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActorList({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { list } = input.body const requester = auth.credentials.iss - - const db = ctx.db.getPrimary() - - const listUri = new AtUri(list) - const collId = lex.ids.AppBskyGraphList - if (listUri.collection !== collId) { - throw new InvalidRequestError(`Invalid collection: expected: ${collId}`) - } - - const muteActorList = async () => { - await ctx.services.graph(db).muteActorList({ - list, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.ADD, - actorDid: requester, - subject: list, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await muteActorList() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.ADD, + actorDid: requester, + subject: list, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts index e35e00202ef..5462d7a7117 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts @@ -1,54 +1,21 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActor({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { actor } = input.body const requester = auth.credentials.iss - const db = ctx.db.getPrimary() - - const subjectDid = await ctx.services.actor(db).getActorDid(actor) - if (!subjectDid) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - if (subjectDid === requester) { - throw new InvalidRequestError('Cannot mute oneself') - } - - const unmuteActor = async () => { - await ctx.services.graph(db).unmuteActor({ - subjectDid, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.REMOVE, - actorDid: requester, - subject: subjectDid, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await unmuteActor() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + const [did] = await ctx.hydrator.actor.getDids([actor]) + if (!did) throw new InvalidRequestError('Actor not found') + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.REMOVE, + actorDid: requester, + subject: did, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts index 18d612d7c95..2c80e42187f 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,45 +1,18 @@ -import assert from 'node:assert' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { MuteOperation_Type } from '../../../../proto/bsync_pb' -import { BsyncClient } from '../../../../bsync' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActorList({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { list } = input.body const requester = auth.credentials.iss - const db = ctx.db.getPrimary() - - const unmuteActorList = async () => { - await ctx.services.graph(db).unmuteActorList({ - list, - mutedByDid: requester, - }) - } - - const addBsyncMuteOp = async (bsyncClient: BsyncClient) => { - await bsyncClient.addMuteOperation({ - type: MuteOperation_Type.REMOVE, - actorDid: requester, - subject: list, - }) - } - - if (ctx.cfg.bsyncOnlyMutes) { - assert(ctx.bsyncClient) - await addBsyncMuteOp(ctx.bsyncClient) - } else { - await unmuteActorList() - if (ctx.bsyncClient) { - try { - await addBsyncMuteOp(ctx.bsyncClient) - } catch (err) { - req.log.warn(err, 'failed to sync mute op to bsync') - } - } - } + await ctx.bsyncClient.addMuteOperation({ + type: MuteOperation_Type.REMOVE, + actorDid: requester, + subject: list, + }) }, }) } 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..f5df1cbf5ca --- /dev/null +++ b/packages/bsky/src/api/app/bsky/labeler/getServices.ts @@ -0,0 +1,46 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { mapDefined } from '@atproto/common' +import { resHeaders } from '../../../util' + +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 hydrateCtx = await ctx.hydrator.createContext({ + viewer, + labelers, + }) + const hydration = await ctx.hydrator.hydrateLabelers(dids, hydrateCtx) + + 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, + }, + headers: resHeaders({ labelers: hydrateCtx.labelers }), + } + }, + }) +} diff --git a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts index 71391457902..afbb5b06780 100644 --- a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,43 +1,74 @@ -import { sql } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { countAll, notSoftDeletedClause } from '../../../../db/util' +import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/getUnreadCount' import AppContext from '../../../../context' +import { + HydrationFnInput, + PresentationFnInput, + SkeletonFnInput, + createPipeline, + noRules, +} from '../../../../pipeline' +import { Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { + const getUnreadCount = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.notification.getUnreadCount({ auth: ctx.authVerifier.standard, handler: async ({ auth, params }) => { - const requester = auth.credentials.iss - if (params.seenAt) { - throw new InvalidRequestError('The seenAt parameter is unsupported') - } - - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const result = await db.db - .selectFrom('notification') - .select(countAll.as('count')) - .innerJoin('actor', 'actor.did', 'notification.did') - .leftJoin('actor_state', 'actor_state.did', 'actor.did') - .innerJoin('record', 'record.uri', 'notification.recordUri') - .where(notSoftDeletedClause(ref('actor'))) - .where(notSoftDeletedClause(ref('record'))) - // Ensure to hit notification_did_sortat_idx, handling case where lastSeenNotifs is null. - .where('notification.did', '=', requester) - .where( - 'notification.sortAt', - '>', - sql`coalesce(${ref('actor_state.lastSeenNotifs')}, ${''})`, - ) - .executeTakeFirst() - - const count = result?.count ?? 0 - + const viewer = auth.credentials.iss + const result = await getUnreadCount({ ...params, viewer }, ctx) return { encoding: 'application/json', - body: { count }, + body: result, } }, }) } + +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + if (params.seenAt) { + throw new InvalidRequestError('The seenAt parameter is unsupported') + } + const res = await ctx.hydrator.dataplane.getUnreadNotificationCount({ + actorDid: params.viewer, + }) + return { + count: res.count, + } +} + +const hydration = async ( + _input: HydrationFnInput, +) => { + return {} +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { skeleton } = input + return { count: skeleton.count } +} + +type Context = { + hydrator: Hydrator + views: Views +} + +type Params = QueryParams & { + viewer: string +} + +type SkeletonState = { + count: number +} diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index ce5274a9da7..722dfe27d27 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -1,16 +1,20 @@ import { InvalidRequestError } from '@atproto/xrpc-server' -import { jsonStringToLex } from '@atproto/lexicon' import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listNotifications' import AppContext from '../../../../context' -import { Database } from '../../../../db' -import { notSoftDeletedClause } from '../../../../db/util' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' -import { BlockAndMuteState, GraphService } from '../../../../services/graph' -import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { getSelfLabels, Labels, LabelService } from '../../../../services/label' -import { createPipeline } from '../../../../pipeline' +import { + createPipeline, + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, +} from '../../../../pipeline' +import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' +import { Views } from '../../../../views' +import { Notification } from '../../../../proto/bsky_pb' +import { didFromUri } from '../../../../hydration/util' +import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const listNotifications = createPipeline( @@ -21,195 +25,100 @@ export default function (server: Server, ctx: AppContext) { ) server.app.bsky.notification.listNotifications({ auth: ctx.authVerifier.standard, - handler: async ({ params, auth }) => { - const db = ctx.db.getReplica() - const actorService = ctx.services.actor(db) - const graphService = ctx.services.graph(db) - const labelService = ctx.services.label(db) + handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss - + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) const result = await listNotifications( - { ...params, viewer }, - { db, actorService, graphService, labelService }, + { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + ctx, ) - return { encoding: 'application/json', body: result, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } const skeleton = async ( - params: Params, - ctx: Context, + input: SkeletonFnInput, ): Promise => { - const { db } = ctx - const { limit, cursor, viewer } = params - const { ref } = db.db.dynamic + const { params, ctx } = input if (params.seenAt) { throw new InvalidRequestError('The seenAt parameter is unsupported') } - if (NotifsKeyset.clearlyBad(cursor)) { - return { params, notifs: [] } + const viewer = params.hydrateCtx.viewer + if (clearlyBadCursor(params.cursor)) { + return { notifs: [] } } - let notifBuilder = db.db - .selectFrom('notification as notif') - .where('notif.did', '=', viewer) - .where((clause) => - clause - .where('reasonSubject', 'is', null) - .orWhereExists( - db.db - .selectFrom('record as subject') - .selectAll() - .whereRef('subject.uri', '=', ref('notif.reasonSubject')), - ), - ) - .select([ - 'notif.author as authorDid', - 'notif.recordUri as uri', - 'notif.recordCid as cid', - 'notif.reason as reason', - 'notif.reasonSubject as reasonSubject', - 'notif.sortAt as indexedAt', - ]) - - const keyset = new NotifsKeyset(ref('notif.sortAt'), ref('notif.recordCid')) - notifBuilder = paginate(notifBuilder, { - cursor, - limit, - keyset, - tryIndex: true, - }) - - const actorStateQuery = db.db - .selectFrom('actor_state') - .selectAll() - .where('did', '=', viewer) - - const [notifs, actorState] = await Promise.all([ - notifBuilder.execute(), - actorStateQuery.executeTakeFirst(), + const [res, lastSeenRes] = await Promise.all([ + ctx.hydrator.dataplane.getNotifications({ + actorDid: viewer, + cursor: params.cursor, + limit: params.limit, + }), + ctx.hydrator.dataplane.getNotificationSeen({ + actorDid: viewer, + }), ]) - + // @NOTE for the first page of results if there's no last-seen time, consider top notification unread + // rather than all notifications. bit of a hack to be more graceful when seen times are out of sync. + let lastSeenDate = lastSeenRes.timestamp?.toDate() + if (!lastSeenDate && !params.cursor) { + lastSeenDate = res.notifications.at(0)?.timestamp?.toDate() + } return { - params, - notifs, - cursor: keyset.packFromResult(notifs), - lastSeenNotifs: actorState?.lastSeenNotifs, + notifs: res.notifications, + cursor: res.cursor || undefined, + lastSeenNotifs: lastSeenDate?.toISOString(), } } -const hydration = async (state: SkeletonState, ctx: Context) => { - const { graphService, actorService, labelService, db } = ctx - const { params, notifs } = state - const { viewer } = params - const dids = notifs.map((notif) => notif.authorDid) - const uris = notifs.map((notif) => notif.uri) - const [actors, records, labels, bam] = await Promise.all([ - actorService.views.profiles(dids, viewer), - getRecordMap(db, uris), - labelService.getLabelsForUris(uris), - graphService.getBlockAndMuteState(dids.map((did) => [viewer, did])), - ]) - return { ...state, actors, records, labels, bam } +const hydration = async ( + input: HydrationFnInput, +) => { + const { skeleton, params, ctx } = input + return ctx.hydrator.hydrateNotifications(skeleton.notifs, params.hydrateCtx) } -const noBlockOrMutes = (state: HydrationState) => { - const { viewer } = state.params - state.notifs = state.notifs.filter( - (item) => - !state.bam.block([viewer, item.authorDid]) && - !state.bam.mute([viewer, item.authorDid]), - ) - return state -} - -const presentation = (state: HydrationState) => { - const { notifs, cursor, actors, records, labels, lastSeenNotifs } = state - const notifications = mapDefined(notifs, (notif) => { - const author = actors[notif.authorDid] - const record = records[notif.uri] - if (!author || !record) return undefined - const recordLabels = labels[notif.uri] ?? [] - const recordSelfLabels = getSelfLabels({ - uri: notif.uri, - cid: notif.cid, - record, - }) - return { - uri: notif.uri, - cid: notif.cid, - author, - reason: notif.reason, - reasonSubject: notif.reasonSubject || undefined, - record, - isRead: lastSeenNotifs ? notif.indexedAt <= lastSeenNotifs : false, - indexedAt: notif.indexedAt, - labels: [...recordLabels, ...recordSelfLabels], - } +const noBlockOrMutes = ( + input: RulesFnInput, +) => { + const { skeleton, hydration, ctx } = input + skeleton.notifs = skeleton.notifs.filter((item) => { + const did = didFromUri(item.uri) + return ( + !ctx.views.viewerBlockExists(did, hydration) && + !ctx.views.viewerMuteExists(did, hydration) + ) }) - return { notifications, cursor, seenAt: lastSeenNotifs } + return skeleton } -const getRecordMap = async ( - db: Database, - uris: string[], -): Promise => { - if (!uris.length) return {} - const { ref } = db.db.dynamic - const recordRows = await db.db - .selectFrom('record') - .select(['uri', 'json']) - .where('uri', 'in', uris) - .where(notSoftDeletedClause(ref('record'))) - .execute() - return recordRows.reduce((acc, { uri, json }) => { - acc[uri] = jsonStringToLex(json) as Record - return acc - }, {} as RecordMap) +const presentation = ( + input: PresentationFnInput, +) => { + const { skeleton, hydration, ctx } = input + const { notifs, lastSeenNotifs, cursor } = skeleton + const notifications = mapDefined(notifs, (notif) => + ctx.views.notification(notif, lastSeenNotifs, hydration), + ) + return { notifications, cursor, seenAt: skeleton.lastSeenNotifs } } type Context = { - db: Database - actorService: ActorService - graphService: GraphService - labelService: LabelService + hydrator: Hydrator + views: Views } type Params = QueryParams & { - viewer: string + hydrateCtx: HydrateCtx & { viewer: string } } type SkeletonState = { - params: Params - notifs: NotifRow[] + notifs: Notification[] lastSeenNotifs?: string cursor?: string } - -type HydrationState = SkeletonState & { - bam: BlockAndMuteState - actors: ActorInfoMap - records: RecordMap - labels: Labels -} - -type RecordMap = { [uri: string]: Record } - -type NotifRow = { - authorDid: string - uri: string - cid: string - reason: string - reasonSubject: string | null - indexedAt: string -} - -class NotifsKeyset extends TimeCidKeyset { - labelResult(result: NotifRow) { - return { primary: result.indexedAt, secondary: result.cid } - } -} diff --git a/packages/bsky/src/api/app/bsky/notification/registerPush.ts b/packages/bsky/src/api/app/bsky/notification/registerPush.ts index abce1cd096c..bd4bcec1f2b 100644 --- a/packages/bsky/src/api/app/bsky/notification/registerPush.ts +++ b/packages/bsky/src/api/app/bsky/notification/registerPush.ts @@ -1,15 +1,12 @@ -import assert from 'node:assert' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Platform } from '../../../../notifications' -import { CourierClient } from '../../../../courier' import { AppPlatform } from '../../../../proto/courier_pb' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ auth: ctx.authVerifier.standard, - handler: async ({ req, auth, input }) => { + handler: async ({ auth, input }) => { const { token, platform, serviceDid, appId } = input.body const did = auth.credentials.iss if (serviceDid !== auth.credentials.aud) { @@ -20,44 +17,17 @@ export default function (server: Server, ctx: AppContext) { 'Unsupported platform: must be "ios", "android", or "web".', ) } - - const db = ctx.db.getPrimary() - - const registerDeviceWithAppview = async () => { - await ctx.services - .actor(db) - .registerPushDeviceToken(did, token, platform as Platform, appId) - } - - const registerDeviceWithCourier = async ( - courierClient: CourierClient, - ) => { - await courierClient.registerDeviceToken({ - did, - token, - platform: - platform === 'ios' - ? AppPlatform.IOS - : platform === 'android' - ? AppPlatform.ANDROID - : AppPlatform.WEB, - appId, - }) - } - - if (ctx.cfg.courierOnlyRegistration) { - assert(ctx.courierClient) - await registerDeviceWithCourier(ctx.courierClient) - } else { - await registerDeviceWithAppview() - if (ctx.courierClient) { - try { - await registerDeviceWithCourier(ctx.courierClient) - } catch (err) { - req.log.warn(err, 'failed to register device token with courier') - } - } - } + await ctx.courierClient.registerDeviceToken({ + did, + token, + platform: + platform === 'ios' + ? AppPlatform.IOS + : platform === 'android' + ? AppPlatform.ANDROID + : AppPlatform.WEB, + appId, + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts index 4b8b614fbad..4c9e66113d6 100644 --- a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts @@ -1,7 +1,6 @@ +import { Timestamp } from '@bufbuild/protobuf' import { Server } from '../../../../lexicon' -import { InvalidRequestError } from '@atproto/xrpc-server' import AppContext from '../../../../context' -import { excluded } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.updateSeen({ @@ -9,25 +8,10 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, auth }) => { const { seenAt } = input.body const viewer = auth.credentials.iss - - let parsed: string - try { - parsed = new Date(seenAt).toISOString() - } catch (_err) { - throw new InvalidRequestError('Invalid date') - } - - const db = ctx.db.getPrimary() - - await db.db - .insertInto('actor_state') - .values({ did: viewer, lastSeenNotifs: parsed }) - .onConflict((oc) => - oc.column('did').doUpdateSet({ - lastSeenNotifs: excluded(db.db, 'lastSeenNotifs'), - }), - ) - .executeTakeFirst() + await ctx.dataplane.updateNotificationSeen({ + actorDid: viewer, + timestamp: Timestamp.fromDate(new Date(seenAt)), + }) }, }) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index c15a5242b0f..d9694382709 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,107 +1,60 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { countAll } from '../../../../db/util' -import { GenericKeyset, paginate } from '../../../../db/pagination' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { GeneratorView } from '../../../../lexicon/types/app/bsky/feed/defs' +import { parseString } from '../../../../hydration/util' +import { clearlyBadCursor, resHeaders } from '../../../util' // THIS IS A TEMPORARY UNSPECCED ROUTE +// @TODO currently mirrors getSuggestedFeeds and ignores the "query" param. +// In the future may take into consideration popularity via likes w/ its own dataplane endpoint. export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopularFeedGenerators({ auth: ctx.authVerifier.standardOptional, - handler: async ({ auth, params }) => { - const { limit, cursor, query } = params - const requester = auth.credentials.iss - if (LikeCountKeyset.clearlyBad(cursor)) { + handler: async ({ auth, params, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers }) + + if (clearlyBadCursor(params.cursor)) { return { encoding: 'application/json', body: { feeds: [] }, } } - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const feedService = ctx.services.feed(db) - const actorService = ctx.services.actor(db) - let inner = db.db - .selectFrom('feed_generator') - .select([ - 'uri', - 'cid', - db.db - .selectFrom('like') - .whereRef('like.subject', '=', ref('feed_generator.uri')) - .select(countAll.as('count')) - .as('likeCount'), - ]) + let uris: string[] + let cursor: string | undefined + const query = params.query?.trim() ?? '' if (query) { - inner = inner.where((qb) => - qb - .where('feed_generator.displayName', 'ilike', `%${query}%`) - .orWhere('feed_generator.description', 'ilike', `%${query}%`), - ) + const res = await ctx.dataplane.searchFeedGenerators({ + query, + limit: params.limit, + }) + uris = res.uris + } else { + const res = await ctx.dataplane.getSuggestedFeeds({ + actorDid: viewer ?? undefined, + limit: params.limit, + cursor: params.cursor, + }) + uris = res.uris + cursor = parseString(res.cursor) } - let builder = db.db.selectFrom(inner.as('feed_gens')).selectAll() - - const keyset = new LikeCountKeyset(ref('likeCount'), ref('cid')) - builder = paginate(builder, { limit, cursor, keyset, direction: 'desc' }) - - const res = await builder.execute() - - const genInfos = await feedService.getFeedGeneratorInfos( - res.map((feed) => feed.uri), - requester, + const hydration = await ctx.hydrator.hydrateFeedGens(uris, hydrateCtx) + const feedViews = mapDefined(uris, (uri) => + ctx.views.feedGenerator(uri, hydration), ) - const creators = Object.values(genInfos).map((gen) => gen.creator) - const profiles = await actorService.views.profiles(creators, requester) - - const genViews: GeneratorView[] = [] - for (const row of res) { - const gen = genInfos[row.uri] - if (!gen) continue - const view = feedService.views.formatFeedGeneratorView(gen, profiles) - if (view) { - genViews.push(view) - } - } - return { encoding: 'application/json', body: { - cursor: keyset.packFromResult(res), - feeds: genViews, + feeds: feedViews, + cursor, }, + headers: resHeaders({ labelers: hydrateCtx.labelers }), } }, }) } - -type Result = { likeCount: number; cid: string } -type LabeledResult = { primary: number; secondary: string } -export class LikeCountKeyset extends GenericKeyset { - labelResult(result: Result) { - return { - primary: result.likeCount, - secondary: result.cid, - } - } - labeledResultToCursor(labeled: LabeledResult) { - return { - primary: labeled.primary.toString(), - secondary: labeled.secondary, - } - } - cursorToLabeledResult(cursor: { primary: string; secondary: string }) { - const likes = parseInt(cursor.primary, 10) - if (isNaN(likes)) { - throw new InvalidRequestError('Malformed cursor') - } - return { - primary: likes, - secondary: cursor.secondary, - } - } -} diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts index 4fd248e87ff..79df4857945 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts @@ -5,11 +5,12 @@ import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getTaggedSuggestions({ handler: async () => { - const suggestions = await ctx.db - .getReplica() - .db.selectFrom('tagged_suggestion') - .selectAll() - .execute() + const res = await ctx.dataplane.getSuggestedEntities({}) + const suggestions = res.entities.map((entity) => ({ + tag: entity.tag, + subjectType: entity.subjectType, + subject: entity.subject, + })) return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/util/feed.ts b/packages/bsky/src/api/app/bsky/util/feed.ts deleted file mode 100644 index 769b2d7e833..00000000000 --- a/packages/bsky/src/api/app/bsky/util/feed.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TimeCidKeyset } from '../../../../db/pagination' -import { FeedRow } from '../../../../services/feed/types' - -export enum FeedAlgorithm { - ReverseChronological = 'reverse-chronological', -} - -export class FeedKeyset extends TimeCidKeyset { - labelResult(result: FeedRow) { - return { primary: result.sortAt, secondary: result.cid } - } -} - -// For users with sparse feeds, avoid scanning more than one week for a single page -export const getFeedDateThreshold = (from: string | undefined, days = 1) => { - const timelineDateThreshold = from ? new Date(from) : new Date() - timelineDateThreshold.setDate(timelineDateThreshold.getDate() - days) - return timelineDateThreshold.toISOString() -} diff --git a/packages/bsky/src/api/blob-resolver.ts b/packages/bsky/src/api/blob-resolver.ts index c307152c43a..facb70c4d6f 100644 --- a/packages/bsky/src/api/blob-resolver.ts +++ b/packages/bsky/src/api/blob-resolver.ts @@ -5,11 +5,16 @@ import axios, { AxiosError } from 'axios' import { CID } from 'multiformats/cid' import { ensureValidDid } from '@atproto/syntax' import { forwardStreamErrors, VerifyCidTransform } from '@atproto/common' -import { IdResolver, DidNotFoundError } from '@atproto/identity' +import { DidNotFoundError } from '@atproto/identity' import AppContext from '../context' import { httpLogger as log } from '../logger' import { retryHttp } from '../util/retry' -import { Database } from '../db' +import { + Code, + getServiceEndpoint, + isDataplaneError, + unpackIdentityServices, +} from '../data-plane' // Resolve and verify blob from its origin host @@ -31,8 +36,7 @@ export const createRouter = (ctx: AppContext): express.Router => { return next(createError(400, 'Invalid cid')) } - const db = ctx.db.getReplica() - const verifiedImage = await resolveBlob(did, cid, db, ctx.idResolver) + const verifiedImage = await resolveBlob(ctx, did, cid) // Send chunked response, destroying stream early (before // closing chunk) if the bytes don't match the expected cid. @@ -76,28 +80,35 @@ export const createRouter = (ctx: AppContext): express.Router => { return router } -export async function resolveBlob( - did: string, - cid: CID, - db: Database, - idResolver: IdResolver, -) { +export async function resolveBlob(ctx: AppContext, did: string, cid: CID) { const cidStr = cid.toString() - const [{ pds }, takedown] = await Promise.all([ - idResolver.did.resolveAtprotoData(did), // @TODO cache did info - db.db - .selectFrom('blob_takedown') - .select('takedownRef') - .where('did', '=', did) - .where('cid', '=', cid.toString()) - .executeTakeFirst(), + const [identity, { takenDown }] = await Promise.all([ + ctx.dataplane.getIdentityByDid({ did }).catch((err) => { + if (isDataplaneError(err, Code.NotFound)) { + return undefined + } + throw err + }), + ctx.dataplane.getBlobTakedown({ did, cid: cid.toString() }), ]) - if (takedown) { + const services = identity && unpackIdentityServices(identity.services) + const pds = + services && + getServiceEndpoint(services, { + id: 'atproto_pds', + type: 'AtprotoPersonalDataServer', + }) + if (!pds) { + throw createError(404, 'Origin not found') + } + if (takenDown) { throw createError(404, 'Blob not found') } - const blobResult = await retryHttp(() => getBlob({ pds, did, cid: cidStr })) + const blobResult = await retryHttp(() => + getBlob(ctx, { pds, did, cid: cidStr }), + ) const imageStream: Readable = blobResult.data const verifyCid = new VerifyCidTransform(cid) @@ -110,12 +121,40 @@ export async function resolveBlob( } } -async function getBlob(opts: { pds: string; did: string; cid: string }) { +async function getBlob( + ctx: AppContext, + opts: { pds: string; did: string; cid: string }, +) { const { pds, did, cid } = opts return axios.get(`${pds}/xrpc/com.atproto.sync.getBlob`, { params: { did, cid }, decompress: true, responseType: 'stream', timeout: 5000, // 5sec of inactivity on the connection + headers: getRateLimitBypassHeaders(ctx, pds), }) } + +function getRateLimitBypassHeaders( + ctx: AppContext, + pds: string, +): { 'x-ratelimit-bypass'?: string } { + const { + blobRateLimitBypassKey: bypassKey, + blobRateLimitBypassHostname: bypassHostname, + } = ctx.cfg + if (!bypassKey || !bypassHostname) { + return {} + } + const url = new URL(pds) + if (bypassHostname.startsWith('.')) { + if (url.hostname.endsWith(bypassHostname)) { + return { 'x-ratelimit-bypass': bypassKey } + } + } else { + if (url.hostname === bypassHostname) { + return { 'x-ratelimit-bypass': bypassKey } + } + } + return {} +} diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index 9ef66c94c9b..c10b55d1048 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -1,33 +1,30 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { Actor } from '../../../../db/tables/actor' import { mapDefined } from '@atproto/common' import { INVALID_HANDLE } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfos({ - auth: ctx.authVerifier.roleOrAdminService, - handler: async ({ params }) => { + auth: ctx.authVerifier.optionalStandardOrRole, + handler: async ({ params, auth }) => { const { dids } = params - const db = ctx.db.getPrimary() - const actorService = ctx.services.actor(db) - const [actors, profiles] = await Promise.all([ - actorService.getActors(dids, true), - actorService.getProfileRecords(dids, true), - ]) - const actorByDid = actors.reduce((acc, cur) => { - return acc.set(cur.did, cur) - }, new Map()) + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth) + + const actors = await ctx.hydrator.actor.getActors(dids, true) const infos = mapDefined(dids, (did) => { - const info = actorByDid.get(did) + const info = actors.get(did) if (!info) return - const profile = profiles.get(did) + if (info.takedownRef && !includeTakedowns) return + const profileRecord = + !info.profileTakedownRef || includeTakedowns + ? info.profile + : undefined return { did, handle: info.handle ?? INVALID_HANDLE, - relatedRecords: profile ? [profile] : undefined, - indexedAt: info.indexedAt, + relatedRecords: profileRecord ? [profileRecord] : undefined, + indexedAt: (info.sortedAt ?? new Date(0)).toISOString(), } }) diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts index 8ac237240f9..3be4c1c0185 100644 --- a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -5,10 +5,10 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params - const modService = ctx.services.moderation(ctx.db.getPrimary()) + let body: OutputSchema | null = null if (blob) { if (!did) { @@ -16,46 +16,48 @@ export default function (server: Server, ctx: AppContext) { 'Must provide a did to request blob state', ) } - const takedown = await modService.getBlobTakedownRef(did, blob) - if (takedown) { - body = { - subject: { - $type: 'com.atproto.admin.defs#repoBlobRef', - did: did, - cid: blob, - }, - takedown, - } + const res = await ctx.dataplane.getBlobTakedown({ + did, + cid: blob, + }) + body = { + subject: { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: did, + cid: blob, + }, + takedown: { + applied: res.takenDown, + ref: res.takedownRef ? 'TAKEDOWN' : undefined, + }, } } else if (uri) { - const [takedown, cidRes] = await Promise.all([ - modService.getRecordTakedownRef(uri), - ctx.db - .getPrimary() - .db.selectFrom('record') - .where('uri', '=', uri) - .select('cid') - .executeTakeFirst(), - ]) - if (cidRes && takedown) { + const res = await ctx.hydrator.getRecord(uri, true) + if (res) { body = { subject: { $type: 'com.atproto.repo.strongRef', uri, - cid: cidRes.cid, + cid: res.cid, + }, + takedown: { + applied: !!res.takedownRef, + ref: res.takedownRef || undefined, }, - takedown, } } } else if (did) { - const takedown = await modService.getRepoTakedownRef(did) - if (takedown) { + const res = (await ctx.hydrator.actor.getActors([did], true)).get(did) + if (res) { body = { subject: { $type: 'com.atproto.admin.defs#repoRef', did: did, }, - takedown, + takedown: { + applied: !!res.takedownRef, + ref: res.takedownRef || undefined, + }, } } } else { diff --git a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts index a7875280137..8256efbe7e5 100644 --- a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -1,4 +1,5 @@ -import { AtUri } from '@atproto/syntax' +import { Timestamp } from '@bufbuild/protobuf' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { @@ -6,12 +7,10 @@ import { isRepoBlobRef, } from '../../../../lexicon/types/com/atproto/admin/defs' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import { CID } from 'multiformats/cid' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth) if (!canPerformTakedown) { @@ -19,43 +18,49 @@ export default function (server: Server, ctx: AppContext) { 'Must be a full moderator to update subject state', ) } - - const modService = ctx.services.moderation(ctx.db.getPrimary()) - + const now = new Date() const { subject, takedown } = input.body if (takedown) { if (isRepoRef(subject)) { - const did = subject.did if (takedown.applied) { - await modService.takedownRepo({ - takedownRef: takedown.ref ?? new Date().toISOString(), - did, + await ctx.dataplane.takedownActor({ + did: subject.did, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownRepo({ did }) + await ctx.dataplane.untakedownActor({ + did: subject.did, + seen: Timestamp.fromDate(now), + }) } } else if (isStrongRef(subject)) { - const uri = new AtUri(subject.uri) - const cid = CID.parse(subject.cid) if (takedown.applied) { - await modService.takedownRecord({ - takedownRef: takedown.ref ?? new Date().toISOString(), - uri, - cid, + await ctx.dataplane.takedownRecord({ + recordUri: subject.uri, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownRecord({ uri }) + await ctx.dataplane.untakedownRecord({ + recordUri: subject.uri, + seen: Timestamp.fromDate(now), + }) } } else if (isRepoBlobRef(subject)) { - const { did, cid } = subject if (takedown.applied) { - await modService.takedownBlob({ - takedownRef: takedown.ref ?? new Date().toISOString(), - did, - cid, + await ctx.dataplane.takedownBlob({ + did: subject.did, + cid: subject.cid, + ref: takedown.ref, + seen: Timestamp.fromDate(now), }) } else { - await modService.reverseTakedownBlob({ did, cid }) + await ctx.dataplane.untakedownBlob({ + did: subject.did, + cid: subject.cid, + seen: Timestamp.fromDate(now), + }) } } else { throw new InvalidRequestError('Invalid subject') diff --git a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts index 30c1d7f8a6f..6cb524c6ec2 100644 --- a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts +++ b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts @@ -7,12 +7,9 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.identity.resolveHandle(async ({ req, params }) => { const handle = ident.normalizeHandle(params.handle || req.hostname) - const db = ctx.db.getReplica() - let did: string | undefined - const user = await ctx.services.actor(db).getActor(handle, true) - if (user) { - did = user.did - } else { + let [did] = await ctx.hydrator.actor.getDids([handle]) + + if (!did) { const publicHostname = ctx.cfg.publicUrl ? new URL(ctx.cfg.publicUrl).hostname : null diff --git a/packages/bsky/src/api/com/atproto/repo/getRecord.ts b/packages/bsky/src/api/com/atproto/repo/getRecord.ts index c42c1fd6b4c..95f2c5eda81 100644 --- a/packages/bsky/src/api/com/atproto/repo/getRecord.ts +++ b/packages/bsky/src/api/com/atproto/repo/getRecord.ts @@ -2,37 +2,28 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { jsonStringToLex } from '@atproto/lexicon' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.getRecord(async ({ params }) => { const { repo, collection, rkey, cid } = params - const db = ctx.db.getReplica() - const did = await ctx.services.actor(db).getActorDid(repo) + const [did] = await ctx.hydrator.actor.getDids([repo]) if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - const uri = AtUri.make(did, collection, rkey) + const uri = AtUri.make(did, collection, rkey).toString() + const result = await ctx.hydrator.getRecord(uri, true) - let builder = db.db - .selectFrom('record') - .selectAll() - .where('uri', '=', uri.toString()) - if (cid) { - builder = builder.where('cid', '=', cid) - } - - const record = await builder.executeTakeFirst() - if (!record) { + if (!result || (cid && result.cid !== cid)) { throw new InvalidRequestError(`Could not locate record: ${uri}`) } + return { - encoding: 'application/json', + encoding: 'application/json' as const, body: { - uri: record.uri, - cid: record.cid, - value: jsonStringToLex(record.json) as Record, + uri: uri, + cid: result.cid, + value: result.record, }, } }) diff --git a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts index 8a6cacc2fbd..044a8d4dfd4 100644 --- a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts +++ b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts @@ -1,30 +1,9 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.fetchLabels(async ({ params }) => { - const { limit } = params - const db = ctx.db.getReplica() - const since = - params.since !== undefined ? new Date(params.since).toISOString() : '' - const labelRes = await db.db - .selectFrom('label') - .selectAll() - .orderBy('label.cts', 'asc') - .where('cts', '>', since) - .limit(limit) - .execute() - - const labels = labelRes.map((l) => ({ - ...l, - cid: l.cid === '' ? undefined : l.cid, - })) - - return { - encoding: 'application/json', - body: { - labels, - }, - } +export default function (server: Server, _ctx: AppContext) { + server.com.atproto.temp.fetchLabels(async (_reqCtx) => { + throw new InvalidRequestError('not implemented on dataplane') }) } diff --git a/packages/bsky/src/api/health.ts b/packages/bsky/src/api/health.ts index b8ebadd4b71..20190cd030c 100644 --- a/packages/bsky/src/api/health.ts +++ b/packages/bsky/src/api/health.ts @@ -1,5 +1,4 @@ import express from 'express' -import { sql } from 'kysely' import AppContext from '../context' export const createRouter = (ctx: AppContext): express.Router => { @@ -21,9 +20,8 @@ export const createRouter = (ctx: AppContext): express.Router => { router.get('/xrpc/_health', async function (req, res) { const { version } = ctx.cfg - const db = ctx.db.getPrimary() try { - await sql`select 1`.execute(db.db) + await ctx.dataplane.ping({}) } catch (err) { req.log.error(err, 'failed health check') return res.status(503).send({ version, error: 'Service Unavailable' }) 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/api/util.ts b/packages/bsky/src/api/util.ts index ef7e51bc95e..3ee0ea2c59b 100644 --- a/packages/bsky/src/api/util.ts +++ b/packages/bsky/src/api/util.ts @@ -1,7 +1,27 @@ -import express from 'express' +import { ParsedLabelers, formatLabelerHeader } from '../util' -export const setRepoRev = (res: express.Response, rev: string | null) => { - if (rev !== null) { - res.setHeader('Atproto-Repo-Rev', rev) +export const ATPROTO_CONTENT_LABELERS = 'Atproto-Content-Labelers' +export const ATPROTO_REPO_REV = 'Atproto-Repo-Rev' + +type ResHeaderOpts = { + labelers: ParsedLabelers + repoRev: string | null +} + +export const resHeaders = ( + opts: Partial, +): Record => { + const headers = {} + if (opts.labelers) { + headers[ATPROTO_CONTENT_LABELERS] = formatLabelerHeader(opts.labelers) } + if (opts.repoRev) { + headers[ATPROTO_REPO_REV] = opts.repoRev + } + return headers +} + +export const clearlyBadCursor = (cursor?: string) => { + // hallmark of v1 cursor, highly unlikely in v2 cursors based on time or rkeys + return !!cursor?.includes('::') } diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 5a2bf753072..44bde797932 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -2,9 +2,16 @@ import { AuthRequiredError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' +import { + Code, + DataPlaneClient, + getKeyAsDidKey, + isDataplaneError, + unpackIdentityKeys, +} from './data-plane' +import { GetIdentityByDidResponse } from './proto/bsky_pb' type ReqCtx = { req: express.Request @@ -18,7 +25,7 @@ export enum RoleStatus { type NullOutput = { credentials: { - type: 'null' + type: 'none' iss: null } } @@ -35,14 +42,12 @@ type RoleOutput = { credentials: { type: 'role' admin: boolean - moderator: boolean - triage: boolean } } -type AdminServiceOutput = { +type ModServiceOutput = { credentials: { - type: 'admin_service' + type: 'mod_service' aud: string iss: string } @@ -50,41 +55,55 @@ type AdminServiceOutput = { export type AuthVerifierOpts = { ownDid: string - adminDid: string - adminPass: string - moderatorPass: string - triagePass: string + modServiceDid: string + adminPasses: string[] } export class AuthVerifier { - private _adminPass: string - private _moderatorPass: string - private _triagePass: string public ownDid: string - public adminDid: string + public modServiceDid: string + private adminPasses: Set - constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { - this._adminPass = opts.adminPass - this._moderatorPass = opts.moderatorPass - this._triagePass = opts.triagePass + constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) { this.ownDid = opts.ownDid - this.adminDid = opts.adminDid + this.modServiceDid = opts.modServiceDid + this.adminPasses = new Set(opts.adminPasses) } // verifiers (arrow fns to preserve scope) standard = async (ctx: ReqCtx): Promise => { + // @TODO remove! basic auth + did supported just for testing. + if (isBasicToken(ctx.req)) { + const aud = this.ownDid + const iss = ctx.req.headers['appview-as-did'] + if (typeof iss !== 'string' || !iss.startsWith('did:')) { + throw new AuthRequiredError('bad issuer') + } + if (!this.parseRoleCreds(ctx.req).admin) { + throw new AuthRequiredError('bad credentials') + } + return { + credentials: { type: 'standard', iss, aud }, + } + } const { iss, aud } = await this.verifyServiceJwt(ctx, { aud: this.ownDid, iss: null, }) - return { credentials: { type: 'standard', iss, aud } } + return { + credentials: { + type: 'standard', + iss, + aud, + }, + } } standardOptional = async ( ctx: ReqCtx, ): Promise => { - if (isBearerToken(ctx.req)) { + if (isBearerToken(ctx.req) || isBasicToken(ctx.req)) { return this.standard(ctx) } return this.nullCreds() @@ -148,19 +167,19 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { + modService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.adminDid], + iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`], }) - return { credentials: { type: 'admin_service', aud, iss } } + return { credentials: { type: 'mod_service', aud, iss } } } - roleOrAdminService = async ( + roleOrModService = async ( reqCtx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(reqCtx.req)) { - return this.adminService(reqCtx) + return this.modService(reqCtx) } else { return this.role(reqCtx) } @@ -173,16 +192,10 @@ export class AuthVerifier { return { status: Missing, admin: false, moderator: false, triage: false } } const { username, password } = parsed - if (username === 'admin' && password === this._adminPass) { - return { status: Valid, admin: true, moderator: true, triage: true } + if (username === 'admin' && this.adminPasses.has(password)) { + return { status: Valid, admin: true } } - if (username === 'admin' && password === this._moderatorPass) { - return { status: Valid, admin: false, moderator: true, triage: true } - } - if (username === 'admin' && password === this._triagePass) { - return { status: Valid, admin: false, moderator: false, triage: true } - } - return { status: Invalid, admin: false, moderator: false, triage: false } + return { status: Invalid, admin: false } } async verifyServiceJwt( @@ -190,13 +203,30 @@ export class AuthVerifier { opts: { aud: string | null; iss: string[] | null }, ) { const getSigningKey = async ( - did: string, - forceRefresh: boolean, + iss: string, + _forceRefresh: boolean, // @TODO consider propagating to dataplane ): Promise => { - if (opts.iss !== null && !opts.iss.includes(did)) { + if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + const [did, serviceId] = iss.split('#') + const keyId = + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' + let identity: GetIdentityByDidResponse + try { + identity = await this.dataplane.getIdentityByDid({ did }) + } catch (err) { + if (isDataplaneError(err, Code.NotFound)) { + throw new AuthRequiredError('identity unknown') + } + throw err + } + const keys = unpackIdentityKeys(identity.keys) + const didKey = getKeyAsDidKey(keys, { id: keyId }) + if (!didKey) { + throw new AuthRequiredError('missing or bad key') + } + return didKey } const jwtStr = bearerTokenFromReq(reqCtx.req) @@ -207,29 +237,39 @@ export class AuthVerifier { return { iss: payload.iss, aud: payload.aud } } + isModService(iss: string): boolean { + return [ + this.modServiceDid, + `${this.modServiceDid}#atproto_labeler`, + ].includes(iss) + } + nullCreds(): NullOutput { return { credentials: { - type: 'null', + type: 'none', iss: null, }, } } parseCreds( - creds: StandardOutput | RoleOutput | AdminServiceOutput | NullOutput, + creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput, ) { const viewer = creds.credentials.type === 'standard' ? creds.credentials.iss : null - const canViewTakedowns = - (creds.credentials.type === 'role' && creds.credentials.triage) || - creds.credentials.type === 'admin_service' + const includeTakedowns = + (creds.credentials.type === 'role' && creds.credentials.admin) || + creds.credentials.type === 'mod_service' || + (creds.credentials.type === 'standard' && + this.isModService(creds.credentials.iss)) const canPerformTakedown = - (creds.credentials.type === 'role' && creds.credentials.moderator) || - creds.credentials.type === 'admin_service' + (creds.credentials.type === 'role' && creds.credentials.admin) || + creds.credentials.type === 'mod_service' + return { viewer, - canViewTakedowns, + includeTakedowns, canPerformTakedown, } } @@ -245,6 +285,10 @@ const isBearerToken = (req: express.Request): boolean => { return req.headers.authorization?.startsWith(BEARER) ?? false } +const isBasicToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BASIC) ?? false +} + const bearerTokenFromReq = (req: express.Request) => { const header = req.headers.authorization || '' if (!header.startsWith(BEARER)) return null diff --git a/packages/bsky/src/auto-moderator/hive.ts b/packages/bsky/src/auto-moderator/hive.ts deleted file mode 100644 index 51d67c1c783..00000000000 --- a/packages/bsky/src/auto-moderator/hive.ts +++ /dev/null @@ -1,187 +0,0 @@ -import axios from 'axios' -import FormData from 'form-data' -import { CID } from 'multiformats/cid' -import { IdResolver } from '@atproto/identity' -import { PrimaryDatabase } from '../db' -import { retryHttp } from '../util/retry' -import { resolveBlob } from '../api/blob-resolver' -import { labelerLogger as log } from '../logger' - -const HIVE_ENDPOINT = 'https://api.thehive.ai/api/v2/task/sync' - -export interface ImgLabeler { - labelImg(did: string, cid: CID): Promise -} - -export class HiveLabeler implements ImgLabeler { - constructor( - public hiveApiKey: string, - protected ctx: { - db: PrimaryDatabase - idResolver: IdResolver - }, - ) {} - - async labelImg(did: string, cid: CID): Promise { - const hiveRes = await retryHttp(async () => { - try { - return await this.makeHiveReq(did, cid) - } catch (err) { - log.warn({ err, did, cid: cid.toString() }, 'hive request failed') - throw err - } - }) - log.info({ hiveRes, did, cid: cid.toString() }, 'hive response') - const classes = respToClasses(hiveRes) - return summarizeLabels(classes) - } - - async makeHiveReq(did: string, cid: CID): Promise { - const { stream } = await resolveBlob( - did, - cid, - this.ctx.db, - this.ctx.idResolver, - ) - const form = new FormData() - form.append('media', stream) - const { data } = await axios.post(HIVE_ENDPOINT, form, { - headers: { - 'Content-Type': 'multipart/form-data', - authorization: `token ${this.hiveApiKey}`, - accept: 'application/json', - }, - }) - return data - } -} - -export const respToClasses = (res: HiveResp): HiveRespClass[] => { - const classes: HiveRespClass[] = [] - for (const status of res.status) { - for (const out of status.response.output) { - for (const cls of out.classes) { - classes.push(cls) - } - } - } - return classes -} - -// Matches only one (or none) of: porn, sexual, nudity -// -// porn: sexual and nudity. including both explicit activity or full-frontal and suggestive/intent -// sexual: sexually suggestive, not explicit; may include some forms of nudity -// nudity: non-sexual nudity (eg, artistic, possibly some photographic) -// -// hive docs/definitions: https://docs.thehive.ai/docs/sexual-content -export const sexualLabels = (classes: HiveRespClass[]): string[] => { - const scores = {} - - for (const cls of classes) { - scores[cls.class] = cls.score - } - - // first check if porn... - for (const pornClass of [ - 'yes_sexual_activity', - 'animal_genitalia_and_human', - 'yes_realistic_nsfw', - ]) { - if (scores[pornClass] >= 0.9) { - return ['porn'] - } - } - if (scores['general_nsfw'] >= 0.9) { - // special case for some anime examples - if (scores['animated_animal_genitalia'] >= 0.5) { - return ['porn'] - } - // special case for some pornographic/explicit classic drawings - if (scores['yes_undressed'] >= 0.9 && scores['yes_sexual_activity'] > 0.9) { - return ['porn'] - } - } - - // then check for sexual suggestive (which may include nudity)... - for (const sexualClass of ['yes_sexual_intent', 'yes_sex_toy']) { - if (scores[sexualClass] >= 0.9) { - return ['sexual'] - } - } - if (scores['yes_undressed'] >= 0.9) { - // special case for bondage examples - if (scores['yes_sex_toy'] > 0.75) { - return ['sexual'] - } - } - - // then non-sexual nudity... - for (const nudityClass of [ - 'yes_male_nudity', - 'yes_female_nudity', - 'yes_undressed', - ]) { - if (scores[nudityClass] >= 0.9) { - return ['nudity'] - } - } - - // then finally flag remaining "underwear" images in to sexually suggestive - // (after non-sexual content already labeled above) - for (const nudityClass of ['yes_male_underwear', 'yes_female_underwear']) { - if (scores[nudityClass] >= 0.9) { - // TODO: retaining 'underwear' label for a short time to help understand - // the impact of labeling all "underwear" as "sexual". This *will* be - // pulling in somewhat non-sexual content in to "sexual" label. - return ['sexual'] - } - } - - return [] -} - -// gore and violence: https://docs.thehive.ai/docs/class-descriptions-violence-gore -const labelForClass = { - very_bloody: 'gore', - human_corpse: 'corpse', - hanging: 'corpse', -} -const labelForClassLessSensitive = { - yes_self_harm: 'self-harm', -} - -export const summarizeLabels = (classes: HiveRespClass[]): string[] => { - const labels: string[] = sexualLabels(classes) - for (const cls of classes) { - if (labelForClass[cls.class] && cls.score >= 0.9) { - labels.push(labelForClass[cls.class]) - } - } - for (const cls of classes) { - if (labelForClassLessSensitive[cls.class] && cls.score >= 0.96) { - labels.push(labelForClassLessSensitive[cls.class]) - } - } - return labels -} - -type HiveResp = { - status: HiveRespStatus[] -} - -type HiveRespStatus = { - response: { - output: HiveRespOutput[] - } -} - -type HiveRespOutput = { - time: number - classes: HiveRespClass[] -} - -type HiveRespClass = { - class: string - score: number -} diff --git a/packages/bsky/src/auto-moderator/index.ts b/packages/bsky/src/auto-moderator/index.ts deleted file mode 100644 index 7883a26d9e4..00000000000 --- a/packages/bsky/src/auto-moderator/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AtUri } from '@atproto/syntax' -import { AtpAgent } from '@atproto/api' -import { dedupe, getFieldsFromRecord } from './util' -import { labelerLogger as log } from '../logger' -import { PrimaryDatabase } from '../db' -import { IdResolver } from '@atproto/identity' -import { BackgroundQueue } from '../background' -import { IndexerConfig } from '../indexer/config' -import { buildBasicAuth } from '../auth-verifier' -import { CID } from 'multiformats/cid' -import { HiveLabeler, ImgLabeler } from './hive' -import { KeywordLabeler, TextLabeler } from './keyword' -import { ids } from '../lexicon/lexicons' - -export class AutoModerator { - public pushAgent: AtpAgent - public imgLabeler?: ImgLabeler - public textLabeler?: TextLabeler - - constructor( - public ctx: { - db: PrimaryDatabase - idResolver: IdResolver - cfg: IndexerConfig - backgroundQueue: BackgroundQueue - }, - ) { - const { hiveApiKey } = ctx.cfg - this.imgLabeler = hiveApiKey ? new HiveLabeler(hiveApiKey, ctx) : undefined - this.textLabeler = new KeywordLabeler(ctx.cfg.labelerKeywords) - - const url = new URL(ctx.cfg.moderationPushUrl) - this.pushAgent = new AtpAgent({ service: url.origin }) - this.pushAgent.api.setHeader( - 'authorization', - buildBasicAuth(url.username, url.password), - ) - } - - processRecord(uri: AtUri, cid: CID, obj: unknown) { - this.ctx.backgroundQueue.add(async () => { - const { text, imgs } = getFieldsFromRecord(obj, uri) - await this.labelRecord(uri, cid, text, imgs).catch((err) => { - log.error( - { err, uri: uri.toString(), record: obj }, - 'failed to label record', - ) - }) - }) - } - - processHandle(_handle: string, _did: string) { - // no-op since this functionality moved to auto-mod service - } - - async labelRecord(uri: AtUri, recordCid: CID, text: string[], imgs: CID[]) { - if (uri.collection !== ids.AppBskyFeedPost) { - // @TODO label profiles - return - } - const allLabels = await Promise.all([ - this.textLabeler?.labelText(text.join(' ')), - ...imgs.map((cid) => this.imgLabeler?.labelImg(uri.host, cid)), - ]) - const labels = dedupe(allLabels.flat()) - await this.pushLabels(uri, recordCid, labels) - } - - async pushLabels(uri: AtUri, cid: CID, labels: string[]): Promise { - if (labels.length < 1) return - - await this.pushAgent.com.atproto.admin.emitModerationEvent({ - event: { - $type: 'com.atproto.admin.defs#modEventLabel', - comment: '[AutoModerator]: Applying labels', - createLabelVals: labels, - negateLabelVals: [], - }, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: uri.toString(), - cid: cid.toString(), - }, - createdBy: this.ctx.cfg.serverDid, - }) - } - - async processAll() { - await this.ctx.backgroundQueue.processAll() - } -} diff --git a/packages/bsky/src/auto-moderator/keyword.ts b/packages/bsky/src/auto-moderator/keyword.ts deleted file mode 100644 index 6bc504aa142..00000000000 --- a/packages/bsky/src/auto-moderator/keyword.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface TextLabeler { - labelText(text: string): Promise -} - -export class KeywordLabeler implements TextLabeler { - constructor(public keywords: Record) {} - - async labelText(text: string): Promise { - return keywordLabeling(this.keywords, text) - } -} - -export const keywordLabeling = ( - keywords: Record, - text: string, -): string[] => { - const lowerText = text.toLowerCase() - const labels: string[] = [] - for (const word of Object.keys(keywords)) { - if (lowerText.includes(word)) { - labels.push(keywords[word]) - } - } - return labels -} diff --git a/packages/bsky/src/auto-moderator/util.ts b/packages/bsky/src/auto-moderator/util.ts deleted file mode 100644 index ab1467a07f2..00000000000 --- a/packages/bsky/src/auto-moderator/util.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { CID } from 'multiformats/cid' -import { AtUri } from '@atproto/syntax' -import * as lex from '../lexicon/lexicons' -import { - isRecord as isPost, - Record as PostRecord, -} from '../lexicon/types/app/bsky/feed/post' -import { - isRecord as isProfile, - Record as ProfileRecord, -} from '../lexicon/types/app/bsky/actor/profile' -import { - isRecord as isList, - Record as ListRecord, -} from '../lexicon/types/app/bsky/graph/list' -import { - isRecord as isGenerator, - Record as GeneratorRecord, -} from '../lexicon/types/app/bsky/feed/generator' -import { isMain as isEmbedImage } from '../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedExternal } from '../lexicon/types/app/bsky/embed/external' -import { isMain as isEmbedRecordWithMedia } from '../lexicon/types/app/bsky/embed/recordWithMedia' - -type RecordFields = { - text: string[] - imgs: CID[] -} - -export const getFieldsFromRecord = ( - record: unknown, - uri: AtUri, -): RecordFields => { - if (isPost(record)) { - return getFieldsFromPost(record) - } else if (isProfile(record)) { - return getFieldsFromProfile(record) - } else if (isList(record)) { - return getFieldsFromList(record) - } else if (isGenerator(record)) { - return getFieldsFromGenerator(record, uri) - } else { - return { text: [], imgs: [] } - } -} - -export const getFieldsFromPost = (record: PostRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - text.push(record.text) - const embeds = separateEmbeds(record.embed) - for (const embed of embeds) { - if (isEmbedImage(embed)) { - for (const img of embed.images) { - imgs.push(img.image.ref) - text.push(img.alt) - } - } else if (isEmbedExternal(embed)) { - if (embed.external.thumb) { - imgs.push(embed.external.thumb.ref) - } - text.push(embed.external.title) - text.push(embed.external.description) - } - } - return { text, imgs } -} - -export const getFieldsFromProfile = (record: ProfileRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - if (record.displayName) { - text.push(record.displayName) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - if (record.banner) { - imgs.push(record.banner.ref) - } - return { text, imgs } -} - -export const getFieldsFromList = (record: ListRecord): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - if (record.name) { - text.push(record.name) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - return { text, imgs } -} - -export const getFieldsFromGenerator = ( - record: GeneratorRecord, - uri: AtUri, -): RecordFields => { - const text: string[] = [] - const imgs: CID[] = [] - text.push(uri.rkey) - if (record.displayName) { - text.push(record.displayName) - } - if (record.description) { - text.push(record.description) - } - if (record.avatar) { - imgs.push(record.avatar.ref) - } - return { text, imgs } -} - -export const dedupe = (strs: (string | undefined)[]): string[] => { - const set = new Set() - for (const str of strs) { - if (str !== undefined) { - set.add(str) - } - } - return [...set] -} - -const separateEmbeds = (embed: PostRecord['embed']) => { - if (!embed) { - return [] - } - if (isEmbedRecordWithMedia(embed)) { - return [{ $type: lex.ids.AppBskyEmbedRecord, ...embed.record }, embed.media] - } - return [embed] -} diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 1d88abb588a..3518f7b42a9 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -1,53 +1,37 @@ -import assert from 'assert' -import { - DAY, - HOUR, - MINUTE, - SECOND, - parseIntWithFallback, -} from '@atproto/common' +import assert from 'node:assert' export interface ServerConfigValues { - version: string + // service + version?: string debugMode?: boolean port?: number publicUrl?: string serverDid: string - feedGenDid?: string - dbPrimaryPostgresUrl: string - dbReplicaPostgresUrls?: string[] - dbReplicaTags?: Record // E.g. { timeline: [0], thread: [1] } - dbPostgresSchema?: string - redisHost?: string // either set redis host, or both sentinel name and hosts - redisSentinelName?: string - redisSentinelHosts?: string[] - redisPassword?: string - didPlcUrl: string - didCacheStaleTTL: number - didCacheMaxTTL: number - labelCacheStaleTTL: number - labelCacheMaxTTL: number - handleResolveNameservers?: string[] - imgUriEndpoint?: string - blobCacheLocation?: string - searchEndpoint?: string - bsyncUrl?: string + // external services + dataplaneUrls: string[] + dataplaneHttpVersion?: '1.1' | '2' + dataplaneIgnoreBadTls?: boolean + bsyncUrl: string bsyncApiKey?: string bsyncHttpVersion?: '1.1' | '2' bsyncIgnoreBadTls?: boolean - bsyncOnlyMutes?: boolean - courierUrl?: string + courierUrl: string courierApiKey?: string courierHttpVersion?: '1.1' | '2' courierIgnoreBadTls?: boolean - courierOnlyRegistration?: boolean - adminPassword: string - moderatorPassword: string - triagePassword: string + searchUrl?: string + cdnUrl?: string + blobRateLimitBypassKey?: string + blobRateLimitBypassHostname?: string + // identity + didPlcUrl: string + handleResolveNameservers?: string[] + // moderation and administration modServiceDid: string - rateLimitsEnabled: boolean - rateLimitBypassKey?: string - rateLimitBypassIps?: string[] + adminPasswords: string[] + labelsFromIssuerDids?: string[] + // misc/dev + blobCacheLocation?: string } export class ServerConfig { @@ -55,149 +39,88 @@ export class ServerConfig { constructor(private cfg: ServerConfigValues) {} static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' + const version = process.env.BSKY_VERSION || undefined const debugMode = process.env.NODE_ENV !== 'production' - const publicUrl = process.env.PUBLIC_URL || undefined - const serverDid = process.env.SERVER_DID || 'did:example:test' - const feedGenDid = process.env.FEED_GEN_DID - const envPort = parseInt(process.env.PORT || '', 10) + const publicUrl = process.env.BSKY_PUBLIC_URL || undefined + const serverDid = process.env.BSKY_SERVER_DID || 'did:example:test' + const envPort = parseInt(process.env.BSKY_PORT || '', 10) const port = isNaN(envPort) ? 2584 : envPort - const redisHost = - overrides?.redisHost || process.env.REDIS_HOST || undefined - const redisSentinelName = - overrides?.redisSentinelName || - process.env.REDIS_SENTINEL_NAME || + const didPlcUrl = process.env.BSKY_DID_PLC_URL || 'http://localhost:2582' + const handleResolveNameservers = process.env.BSKY_HANDLE_RESOLVE_NAMESERVERS + ? process.env.BSKY_HANDLE_RESOLVE_NAMESERVERS.split(',') + : [] + const cdnUrl = process.env.BSKY_CDN_URL || process.env.BSKY_IMG_URI_ENDPOINT + const blobCacheLocation = process.env.BSKY_BLOB_CACHE_LOC + const searchUrl = + process.env.BSKY_SEARCH_URL || + process.env.BSKY_SEARCH_ENDPOINT || undefined - const redisSentinelHosts = - overrides?.redisSentinelHosts || - (process.env.REDIS_SENTINEL_HOSTS - ? process.env.REDIS_SENTINEL_HOSTS.split(',') - : []) - const redisPassword = - overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined - const didPlcUrl = process.env.DID_PLC_URL || 'http://localhost:2582' - const didCacheStaleTTL = parseIntWithFallback( - process.env.DID_CACHE_STALE_TTL, - HOUR, - ) - const didCacheMaxTTL = parseIntWithFallback( - process.env.DID_CACHE_MAX_TTL, - DAY, - ) - const labelCacheStaleTTL = parseIntWithFallback( - process.env.LABEL_CACHE_STALE_TTL, - 30 * SECOND, - ) - const labelCacheMaxTTL = parseIntWithFallback( - process.env.LABEL_CACHE_MAX_TTL, - MINUTE, - ) - const handleResolveNameservers = process.env.HANDLE_RESOLVE_NAMESERVERS - ? process.env.HANDLE_RESOLVE_NAMESERVERS.split(',') + let dataplaneUrls = overrides?.dataplaneUrls + dataplaneUrls ??= process.env.BSKY_DATAPLANE_URLS + ? process.env.BSKY_DATAPLANE_URLS.split(',') + : [] + const dataplaneHttpVersion = process.env.BSKY_DATAPLANE_HTTP_VERSION || '2' + const dataplaneIgnoreBadTls = + process.env.BSKY_DATAPLANE_IGNORE_BAD_TLS === 'true' + const labelsFromIssuerDids = process.env.BSKY_LABELS_FROM_ISSUER_DIDS + ? process.env.BSKY_LABELS_FROM_ISSUER_DIDS.split(',') : [] - const imgUriEndpoint = process.env.IMG_URI_ENDPOINT - const blobCacheLocation = process.env.BLOB_CACHE_LOC - const searchEndpoint = process.env.SEARCH_ENDPOINT const bsyncUrl = process.env.BSKY_BSYNC_URL || undefined + assert(bsyncUrl) const bsyncApiKey = process.env.BSKY_BSYNC_API_KEY || undefined const bsyncHttpVersion = process.env.BSKY_BSYNC_HTTP_VERSION || '2' const bsyncIgnoreBadTls = process.env.BSKY_BSYNC_IGNORE_BAD_TLS === 'true' - const bsyncOnlyMutes = process.env.BSKY_BSYNC_ONLY_MUTES === 'true' - assert(!bsyncOnlyMutes || bsyncUrl, 'bsync-only mutes requires a bsync url') assert(bsyncHttpVersion === '1.1' || bsyncHttpVersion === '2') const courierUrl = process.env.BSKY_COURIER_URL || undefined + assert(courierUrl) const courierApiKey = process.env.BSKY_COURIER_API_KEY || undefined const courierHttpVersion = process.env.BSKY_COURIER_HTTP_VERSION || '2' const courierIgnoreBadTls = process.env.BSKY_COURIER_IGNORE_BAD_TLS === 'true' - const courierOnlyRegistration = - process.env.BSKY_COURIER_ONLY_REGISTRATION === 'true' - assert( - !courierOnlyRegistration || courierUrl, - 'courier-only registration requires a courier url', - ) assert(courierHttpVersion === '1.1' || courierHttpVersion === '2') - const dbPrimaryPostgresUrl = - overrides?.dbPrimaryPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - let dbReplicaPostgresUrls = overrides?.dbReplicaPostgresUrls - if (!dbReplicaPostgresUrls && process.env.DB_REPLICA_POSTGRES_URLS) { - dbReplicaPostgresUrls = process.env.DB_REPLICA_POSTGRES_URLS.split(',') - } - const dbReplicaTags = overrides?.dbReplicaTags ?? { - '*': getTagIdxs(process.env.DB_REPLICA_TAGS_ANY), // e.g. DB_REPLICA_TAGS_ANY=0,1 - timeline: getTagIdxs(process.env.DB_REPLICA_TAGS_TIMELINE), - feed: getTagIdxs(process.env.DB_REPLICA_TAGS_FEED), - search: getTagIdxs(process.env.DB_REPLICA_TAGS_SEARCH), - thread: getTagIdxs(process.env.DB_REPLICA_TAGS_THREAD), - } + const blobRateLimitBypassKey = + process.env.BSKY_BLOB_RATE_LIMIT_BYPASS_KEY || undefined + // single domain would be e.g. "mypds.com", subdomains are supported with a leading dot e.g. ".mypds.com" + const blobRateLimitBypassHostname = + process.env.BSKY_BLOB_RATE_LIMIT_BYPASS_HOSTNAME || undefined assert( - Object.values(dbReplicaTags) - .flat() - .every((idx) => idx < (dbReplicaPostgresUrls?.length ?? 0)), - 'out of range index in replica tags', + !blobRateLimitBypassKey || blobRateLimitBypassHostname, + 'must specify a hostname when using a blob rate limit bypass key', ) - const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA - assert(dbPrimaryPostgresUrl) - const adminPassword = process.env.ADMIN_PASSWORD || undefined - assert(adminPassword) - const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined - assert(moderatorPassword) - const triagePassword = process.env.TRIAGE_PASSWORD || undefined - assert(triagePassword) - const modServiceDid = - overrides?.modServiceDid || - process.env.MODERATION_SERVICE_DID || - undefined + const adminPasswords = envList( + process.env.BSKY_ADMIN_PASSWORDS || process.env.BSKY_ADMIN_PASSWORD, + ) + const modServiceDid = process.env.MOD_SERVICE_DID assert(modServiceDid) - const rateLimitsEnabled = process.env.RATE_LIMITS_ENABLED === 'true' - const rateLimitBypassKey = process.env.RATE_LIMIT_BYPASS_KEY - const rateLimitBypassIps = process.env.RATE_LIMIT_BYPASS_IPS - ? process.env.RATE_LIMIT_BYPASS_IPS.split(',').map((ipOrCidr) => - ipOrCidr.split('/')[0]?.trim(), - ) - : undefined - + assert(dataplaneUrls.length) + assert(dataplaneHttpVersion === '1.1' || dataplaneHttpVersion === '2') return new ServerConfig({ version, debugMode, port, publicUrl, serverDid, - feedGenDid, - dbPrimaryPostgresUrl, - dbReplicaPostgresUrls, - dbReplicaTags, - dbPostgresSchema, - redisHost, - redisSentinelName, - redisSentinelHosts, - redisPassword, + dataplaneUrls, + dataplaneHttpVersion, + dataplaneIgnoreBadTls, + searchUrl, didPlcUrl, - didCacheStaleTTL, - didCacheMaxTTL, - labelCacheStaleTTL, - labelCacheMaxTTL, + labelsFromIssuerDids, handleResolveNameservers, - imgUriEndpoint, + cdnUrl, blobCacheLocation, - searchEndpoint, bsyncUrl, bsyncApiKey, bsyncHttpVersion, bsyncIgnoreBadTls, - bsyncOnlyMutes, courierUrl, courierApiKey, courierHttpVersion, courierIgnoreBadTls, - courierOnlyRegistration, - adminPassword, - moderatorPassword, - triagePassword, + blobRateLimitBypassKey, + blobRateLimitBypassHostname, + adminPasswords, modServiceDid, - rateLimitsEnabled, - rateLimitBypassKey, - rateLimitBypassIps, ...stripUndefineds(overrides ?? {}), }) } @@ -235,76 +158,16 @@ export class ServerConfig { return this.cfg.serverDid } - get feedGenDid() { - return this.cfg.feedGenDid - } - - get dbPrimaryPostgresUrl() { - return this.cfg.dbPrimaryPostgresUrl - } - - get dbReplicaPostgresUrl() { - return this.cfg.dbReplicaPostgresUrls - } - - get dbReplicaTags() { - return this.cfg.dbReplicaTags - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema - } - - get redisHost() { - return this.cfg.redisHost - } - - get redisSentinelName() { - return this.cfg.redisSentinelName - } - - get redisSentinelHosts() { - return this.cfg.redisSentinelHosts - } - - get redisPassword() { - return this.cfg.redisPassword - } - - get didCacheStaleTTL() { - return this.cfg.didCacheStaleTTL + get dataplaneUrls() { + return this.cfg.dataplaneUrls } - get didCacheMaxTTL() { - return this.cfg.didCacheMaxTTL + get dataplaneHttpVersion() { + return this.cfg.dataplaneHttpVersion } - get labelCacheStaleTTL() { - return this.cfg.labelCacheStaleTTL - } - - get labelCacheMaxTTL() { - return this.cfg.labelCacheMaxTTL - } - - get handleResolveNameservers() { - return this.cfg.handleResolveNameservers - } - - get didPlcUrl() { - return this.cfg.didPlcUrl - } - - get imgUriEndpoint() { - return this.cfg.imgUriEndpoint - } - - get blobCacheLocation() { - return this.cfg.blobCacheLocation - } - - get searchEndpoint() { - return this.cfg.searchEndpoint + get dataplaneIgnoreBadTls() { + return this.cfg.dataplaneIgnoreBadTls } get bsyncUrl() { @@ -315,10 +178,6 @@ export class ServerConfig { return this.cfg.bsyncApiKey } - get bsyncOnlyMutes() { - return this.cfg.bsyncOnlyMutes - } - get bsyncHttpVersion() { return this.cfg.bsyncHttpVersion } @@ -343,41 +202,45 @@ export class ServerConfig { return this.cfg.courierIgnoreBadTls } - get courierOnlyRegistration() { - return this.cfg.courierOnlyRegistration + get searchUrl() { + return this.cfg.searchUrl } - get adminPassword() { - return this.cfg.adminPassword + get cdnUrl() { + return this.cfg.cdnUrl } - get moderatorPassword() { - return this.cfg.moderatorPassword + get blobRateLimitBypassKey() { + return this.cfg.blobRateLimitBypassKey } - get triagePassword() { - return this.cfg.triagePassword + get blobRateLimitBypassHostname() { + return this.cfg.blobRateLimitBypassHostname } - get modServiceDid() { - return this.cfg.modServiceDid + get didPlcUrl() { + return this.cfg.didPlcUrl + } + + get handleResolveNameservers() { + return this.cfg.handleResolveNameservers } - get rateLimitsEnabled() { - return this.cfg.rateLimitsEnabled + get adminPasswords() { + return this.cfg.adminPasswords } - get rateLimitBypassKey() { - return this.cfg.rateLimitBypassKey + get modServiceDid() { + return this.cfg.modServiceDid } - get rateLimitBypassIps() { - return this.cfg.rateLimitBypassIps + get labelsFromIssuerDids() { + return this.cfg.labelsFromIssuerDids ?? [] } -} -function getTagIdxs(str?: string): number[] { - return str ? str.split(',').map((item) => parseInt(item, 10)) : [] + get blobCacheLocation() { + return this.cfg.blobCacheLocation + } } function stripUndefineds( @@ -391,3 +254,8 @@ function stripUndefineds( }) return result } + +function envList(str: string | undefined): string[] { + if (str === undefined || str.length === 0) return [] + return str.split(',') +} diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index db9779e54b9..e8a15f5197a 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -1,52 +1,57 @@ +import express from 'express' import * as plc from '@did-plc/lib' import { IdResolver } from '@atproto/identity' -import { AtpAgent } from '@atproto/api' +import AtpAgent from '@atproto/api' import { Keypair } from '@atproto/crypto' import { createServiceJwt } from '@atproto/xrpc-server' -import { DatabaseCoordinator } from './db' import { ServerConfig } from './config' -import { ImageUriBuilder } from './image/uri' -import { Services } from './services' -import DidRedisCache from './did-cache' -import { BackgroundQueue } from './background' -import { Redis } from './redis' +import { DataPlaneClient } from './data-plane/client' +import { Hydrator } from './hydration/hydrator' +import { Views } from './views' import { AuthVerifier } from './auth-verifier' import { BsyncClient } from './bsync' import { CourierClient } from './courier' +import { + ParsedLabelers, + defaultLabelerHeader, + parseLabelerHeader, +} from './util' +import { httpLogger as log } from './logger' export class AppContext { constructor( private opts: { - db: DatabaseCoordinator - imgUriBuilder: ImageUriBuilder cfg: ServerConfig - services: Services + dataplane: DataPlaneClient + searchAgent: AtpAgent | undefined + hydrator: Hydrator + views: Views signingKey: Keypair idResolver: IdResolver - didCache: DidRedisCache - redis: Redis - backgroundQueue: BackgroundQueue - searchAgent?: AtpAgent - bsyncClient?: BsyncClient - courierClient?: CourierClient + bsyncClient: BsyncClient + courierClient: CourierClient authVerifier: AuthVerifier }, ) {} - get db(): DatabaseCoordinator { - return this.opts.db + get cfg(): ServerConfig { + return this.opts.cfg } - get imgUriBuilder(): ImageUriBuilder { - return this.opts.imgUriBuilder + get dataplane(): DataPlaneClient { + return this.opts.dataplane } - get cfg(): ServerConfig { - return this.opts.cfg + get searchAgent(): AtpAgent | undefined { + return this.opts.searchAgent + } + + get hydrator(): Hydrator { + return this.opts.hydrator } - get services(): Services { - return this.opts.services + get views(): Views { + return this.opts.views } get signingKey(): Keypair { @@ -61,23 +66,11 @@ export class AppContext { return this.opts.idResolver } - get didCache(): DidRedisCache { - return this.opts.didCache - } - - get redis(): Redis { - return this.opts.redis - } - - get searchAgent(): AtpAgent | undefined { - return this.opts.searchAgent - } - - get bsyncClient(): BsyncClient | undefined { + get bsyncClient(): BsyncClient { return this.opts.bsyncClient } - get courierClient(): CourierClient | undefined { + get courierClient(): CourierClient { return this.opts.courierClient } @@ -94,8 +87,17 @@ export class AppContext { }) } - get backgroundQueue(): BackgroundQueue { - return this.opts.backgroundQueue + reqLabelers(req: express.Request): ParsedLabelers { + const val = req.header('atproto-accept-labelers') + let parsed: ParsedLabelers | null + try { + parsed = parseLabelerHeader(val) + } catch (err) { + parsed = null + log.info({ err, val }, 'failed to parse labeler header') + } + if (!parsed) return defaultLabelerHeader(this.cfg.labelsFromIssuerDids) + return parsed } } diff --git a/packages/bsky/src/daemon/config.ts b/packages/bsky/src/daemon/config.ts deleted file mode 100644 index 3dd7d557652..00000000000 --- a/packages/bsky/src/daemon/config.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'assert' - -export interface DaemonConfigValues { - version: string - dbPostgresUrl: string - dbPostgresSchema?: string - notificationsDaemonFromDid?: string -} - -export class DaemonConfig { - constructor(private cfg: DaemonConfigValues) {} - - static readEnv(overrides?: Partial) { - const version = process.env.BSKY_VERSION || '0.0.0' - const dbPostgresUrl = - overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL - const dbPostgresSchema = - overrides?.dbPostgresSchema || process.env.DB_POSTGRES_SCHEMA - const notificationsDaemonFromDid = - overrides?.notificationsDaemonFromDid || - process.env.BSKY_NOTIFS_DAEMON_FROM_DID || - undefined - assert(dbPostgresUrl) - return new DaemonConfig({ - version, - dbPostgresUrl, - dbPostgresSchema, - notificationsDaemonFromDid, - ...stripUndefineds(overrides ?? {}), - }) - } - - get version() { - return this.cfg.version - } - - get dbPostgresUrl() { - return this.cfg.dbPostgresUrl - } - - get dbPostgresSchema() { - return this.cfg.dbPostgresSchema - } - - get notificationsDaemonFromDid() { - return this.cfg.notificationsDaemonFromDid - } -} - -function stripUndefineds( - obj: Record, -): Record { - const result = {} - Object.entries(obj).forEach(([key, val]) => { - if (val !== undefined) { - result[key] = val - } - }) - return result -} diff --git a/packages/bsky/src/daemon/context.ts b/packages/bsky/src/daemon/context.ts deleted file mode 100644 index dd3d5c1114f..00000000000 --- a/packages/bsky/src/daemon/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { DaemonConfig } from './config' -import { Services } from './services' - -export class DaemonContext { - constructor( - private opts: { - db: PrimaryDatabase - cfg: DaemonConfig - services: Services - }, - ) {} - - get db(): PrimaryDatabase { - return this.opts.db - } - - get cfg(): DaemonConfig { - return this.opts.cfg - } - - get services(): Services { - return this.opts.services - } -} - -export default DaemonContext diff --git a/packages/bsky/src/daemon/index.ts b/packages/bsky/src/daemon/index.ts deleted file mode 100644 index 80da01edc2f..00000000000 --- a/packages/bsky/src/daemon/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { dbLogger } from '../logger' -import { DaemonConfig } from './config' -import { DaemonContext } from './context' -import { createServices } from './services' -import { ImageUriBuilder } from '../image/uri' -import { NotificationsDaemon } from './notifications' -import logger from './logger' - -export { DaemonConfig } from './config' -export type { DaemonConfigValues } from './config' - -export class BskyDaemon { - public ctx: DaemonContext - public notifications: NotificationsDaemon - private dbStatsInterval: NodeJS.Timer - private notifStatsInterval: NodeJS.Timer - - constructor(opts: { - ctx: DaemonContext - notifications: NotificationsDaemon - }) { - this.ctx = opts.ctx - this.notifications = opts.notifications - } - - static create(opts: { db: PrimaryDatabase; cfg: DaemonConfig }): BskyDaemon { - const { db, cfg } = opts - const imgUriBuilder = new ImageUriBuilder('https://daemon.invalid') // will not be used by daemon - const services = createServices({ - imgUriBuilder, - }) - const ctx = new DaemonContext({ - db, - cfg, - services, - }) - const notifications = new NotificationsDaemon(ctx) - return new BskyDaemon({ ctx, notifications }) - } - - async start() { - const { db, cfg } = this.ctx - const pool = db.pool - this.notifications.run({ - startFromDid: cfg.notificationsDaemonFromDid, - }) - this.dbStatsInterval = setInterval(() => { - dbLogger.info( - { - idleCount: pool.idleCount, - totalCount: pool.totalCount, - waitingCount: pool.waitingCount, - }, - 'db pool stats', - ) - }, 10000) - this.notifStatsInterval = setInterval(() => { - logger.info( - { - count: this.notifications.count, - lastDid: this.notifications.lastDid, - }, - 'notifications daemon stats', - ) - }, 10000) - return this - } - - async destroy(): Promise { - await this.notifications.destroy() - await this.ctx.db.close() - clearInterval(this.dbStatsInterval) - clearInterval(this.notifStatsInterval) - } -} - -export default BskyDaemon diff --git a/packages/bsky/src/daemon/logger.ts b/packages/bsky/src/daemon/logger.ts deleted file mode 100644 index 8599acc315e..00000000000 --- a/packages/bsky/src/daemon/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { subsystemLogger } from '@atproto/common' - -const logger: ReturnType = - subsystemLogger('bsky:daemon') - -export default logger diff --git a/packages/bsky/src/daemon/notifications.ts b/packages/bsky/src/daemon/notifications.ts deleted file mode 100644 index 96431af8c1f..00000000000 --- a/packages/bsky/src/daemon/notifications.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { tidyNotifications } from '../services/util/notification' -import DaemonContext from './context' -import logger from './logger' - -export class NotificationsDaemon { - ac = new AbortController() - running: Promise | undefined - count = 0 - lastDid: string | null = null - - constructor(private ctx: DaemonContext) {} - - run(opts?: RunOptions) { - if (this.running) return - this.count = 0 - this.lastDid = null - this.ac = new AbortController() - this.running = this.tidyNotifications({ - ...opts, - forever: opts?.forever !== false, // run forever by default - }) - .catch((err) => { - // allow this to cause an unhandled rejection, let deployment handle the crash. - logger.error({ err }, 'notifications daemon crashed') - throw err - }) - .finally(() => (this.running = undefined)) - } - - private async tidyNotifications(opts: RunOptions) { - const actorService = this.ctx.services.actor(this.ctx.db) - for await (const { did } of actorService.all(opts)) { - if (this.ac.signal.aborted) return - try { - await tidyNotifications(this.ctx.db, did) - this.count++ - this.lastDid = did - } catch (err) { - logger.warn({ err, did }, 'failed to tidy notifications for actor') - } - } - } - - async destroy() { - this.ac.abort() - await this.running - } -} - -type RunOptions = { - forever?: boolean - batchSize?: number - startFromDid?: string -} diff --git a/packages/bsky/src/daemon/services.ts b/packages/bsky/src/daemon/services.ts deleted file mode 100644 index 93141d13a08..00000000000 --- a/packages/bsky/src/daemon/services.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PrimaryDatabase } from '../db' -import { ActorService } from '../services/actor' -import { ImageUriBuilder } from '../image/uri' -import { GraphService } from '../services/graph' -import { LabelService } from '../services/label' - -export function createServices(resources: { - imgUriBuilder: ImageUriBuilder -}): Services { - const { imgUriBuilder } = resources - const graph = GraphService.creator(imgUriBuilder) - const label = LabelService.creator(null) - return { - actor: ActorService.creator(imgUriBuilder, graph, label), - } -} - -export type Services = { - actor: FromDbPrimary -} - -type FromDbPrimary = (db: PrimaryDatabase) => T diff --git a/packages/bsky/src/data-plane/bsync/index.ts b/packages/bsky/src/data-plane/bsync/index.ts new file mode 100644 index 00000000000..a1c82bc1126 --- /dev/null +++ b/packages/bsky/src/data-plane/bsync/index.ts @@ -0,0 +1,98 @@ +import http from 'http' +import events from 'events' +import express from 'express' +import { ConnectRouter } from '@connectrpc/connect' +import { expressConnectMiddleware } from '@connectrpc/connect-express' +import { Database } from '../server/db' +import { Service } from '../../proto/bsync_connect' +import { MuteOperation_Type } from '../../proto/bsync_pb' +import assert from 'assert' + +export class MockBsync { + constructor(public server: http.Server) {} + + static async create(db: Database, port: number) { + const app = express() + const routes = createRoutes(db) + app.use(expressConnectMiddleware({ routes })) + const server = app.listen(port) + await events.once(server, 'listening') + return new MockBsync(server) + } + + async destroy() { + return new Promise((resolve, reject) => { + this.server.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } +} + +const createRoutes = (db: Database) => (router: ConnectRouter) => + router.service(Service, { + async addMuteOperation(req) { + const { type, actorDid, subject } = req + if (type === MuteOperation_Type.ADD) { + if (subject.startsWith('did:')) { + assert(actorDid !== subject, 'cannot mute yourself') // @TODO pass message through in http error + await db.db + .insertInto('mute') + .values({ + mutedByDid: actorDid, + subjectDid: subject, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + } else { + await db.db + .insertInto('list_mute') + .values({ + mutedByDid: actorDid, + listUri: subject, + createdAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + } + } else if (type === MuteOperation_Type.REMOVE) { + if (subject.startsWith('did:')) { + await db.db + .deleteFrom('mute') + .where('mutedByDid', '=', actorDid) + .where('subjectDid', '=', subject) + .execute() + } else { + await db.db + .deleteFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .where('listUri', '=', subject) + .execute() + } + } else if (type === MuteOperation_Type.CLEAR) { + await db.db + .deleteFrom('mute') + .where('mutedByDid', '=', actorDid) + .execute() + await db.db + .deleteFrom('list_mute') + .where('mutedByDid', '=', actorDid) + .execute() + } + + return {} + }, + + async scanMuteOperations() { + throw new Error('not implemented') + }, + + async ping() { + return {} + }, + }) diff --git a/packages/bsky/src/data-plane/client.ts b/packages/bsky/src/data-plane/client.ts new file mode 100644 index 00000000000..dd525267fe3 --- /dev/null +++ b/packages/bsky/src/data-plane/client.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert' +import { randomInt } from 'node:crypto' +import * as ui8 from 'uint8arrays' +import { + Code, + ConnectError, + PromiseClient, + createPromiseClient, + makeAnyClient, +} from '@connectrpc/connect' +import { createGrpcTransport } from '@connectrpc/connect-node' +import { getDidKeyFromMultibase } from '@atproto/identity' +import { Service } from '../proto/bsky_connect' + +export type DataPlaneClient = PromiseClient +type BaseClient = { lib: DataPlaneClient; url: URL } +type HttpVersion = '1.1' | '2' +const MAX_RETRIES = 3 + +export const createDataPlaneClient = ( + baseUrls: string[], + opts: { httpVersion?: HttpVersion; rejectUnauthorized?: boolean }, +) => { + const clients = baseUrls.map((baseUrl) => createBaseClient(baseUrl, opts)) + assert(clients.length > 0, 'no clients available') + return makeAnyClient(Service, (method) => { + return async (...args) => { + let tries = 0 + let error: unknown + let remainingClients = clients + while (tries < MAX_RETRIES) { + const client = randomElement(remainingClients) + assert(client, 'no clients available') + try { + return await client.lib[method.localName](...args) + } catch (err) { + if (err instanceof ConnectError && err.code === Code.Unavailable) { + tries++ + error = err + remainingClients = getRemainingClients(remainingClients, client) + } else { + throw err + } + } + } + assert(error) + throw error + } + }) as DataPlaneClient +} + +export { Code } + +export const isDataplaneError = ( + err: unknown, + code?: Code, +): err is ConnectError => { + if (err instanceof ConnectError) { + return !code || err.code === code + } + return false +} + +const createBaseClient = ( + baseUrl: string, + opts: { httpVersion?: HttpVersion; rejectUnauthorized?: boolean }, +): BaseClient => { + const { httpVersion = '2', rejectUnauthorized = true } = opts + const transport = createGrpcTransport({ + baseUrl, + httpVersion, + acceptCompression: [], + nodeOptions: { rejectUnauthorized }, + }) + return { + lib: createPromiseClient(Service, transport), + url: new URL(baseUrl), + } +} + +const getRemainingClients = (clients: BaseClient[], lastClient: BaseClient) => { + if (clients.length < 2) return clients // no clients to choose from + if (lastClient.url.port) { + // if the last client had a port, we attempt to exclude its whole host. + const maybeRemaining = clients.filter( + (c) => c.url.hostname !== lastClient.url.hostname, + ) + if (maybeRemaining.length) { + return maybeRemaining + } + } + return clients.filter((c) => c !== lastClient) +} + +const randomElement = (arr: T[]): T | undefined => { + if (arr.length === 0) return + return arr[randomInt(arr.length)] +} + +export const unpackIdentityServices = (servicesBytes: Uint8Array) => { + const servicesStr = ui8.toString(servicesBytes, 'utf8') + if (!servicesStr) return {} + return JSON.parse(servicesStr) as UnpackedServices +} + +export const unpackIdentityKeys = (keysBytes: Uint8Array) => { + const keysStr = ui8.toString(keysBytes, 'utf8') + if (!keysStr) return {} + return JSON.parse(keysStr) as UnpackedKeys +} + +export const getServiceEndpoint = ( + services: UnpackedServices, + opts: { id: string; type: string }, +) => { + const endpoint = + services[opts.id] && + services[opts.id].Type === opts.type && + validateUrl(services[opts.id].URL) + return endpoint || undefined +} + +export const getKeyAsDidKey = (keys: UnpackedKeys, opts: { id: string }) => { + const key = + keys[opts.id] && + getDidKeyFromMultibase({ + type: keys[opts.id].Type, + publicKeyMultibase: keys[opts.id].PublicKeyMultibase, + }) + return key || undefined +} + +type UnpackedServices = Record + +type UnpackedKeys = Record + +const validateUrl = (urlStr: string): string | undefined => { + let url + try { + url = new URL(urlStr) + } catch { + return undefined + } + if (!['http:', 'https:'].includes(url.protocol)) { + return undefined + } else if (!url.hostname) { + return undefined + } else { + return urlStr + } +} diff --git a/packages/bsky/src/data-plane/index.ts b/packages/bsky/src/data-plane/index.ts new file mode 100644 index 00000000000..31fe4b0dde3 --- /dev/null +++ b/packages/bsky/src/data-plane/index.ts @@ -0,0 +1,3 @@ +export * from './server' +export * from './client' +export * from './bsync' diff --git a/packages/bsky/src/background.ts b/packages/bsky/src/data-plane/server/background.ts similarity index 76% rename from packages/bsky/src/background.ts rename to packages/bsky/src/data-plane/server/background.ts index 466bad80a51..59d8ccf0ddf 100644 --- a/packages/bsky/src/background.ts +++ b/packages/bsky/src/data-plane/server/background.ts @@ -1,13 +1,13 @@ import PQueue from 'p-queue' -import { PrimaryDatabase } from './db' -import { dbLogger } from './logger' +import { Database } from './db' +import { dbLogger } from '../../logger' // A simple queue for in-process, out-of-band/backgrounded work export class BackgroundQueue { - queue = new PQueue({ concurrency: 20 }) + queue = new PQueue() destroyed = false - constructor(public db: PrimaryDatabase) {} + constructor(public db: Database) {} add(task: Task) { if (this.destroyed) { @@ -32,4 +32,4 @@ export class BackgroundQueue { } } -type Task = (db: PrimaryDatabase) => Promise +type Task = (db: Database) => Promise diff --git a/packages/bsky/src/db/database-schema.ts b/packages/bsky/src/data-plane/server/db/database-schema.ts similarity index 92% rename from packages/bsky/src/db/database-schema.ts rename to packages/bsky/src/data-plane/server/db/database-schema.ts index df28c8b91d8..8fca84b2b73 100644 --- a/packages/bsky/src/db/database-schema.ts +++ b/packages/bsky/src/data-plane/server/db/database-schema.ts @@ -24,7 +24,7 @@ import * as actorSync from './tables/actor-sync' import * as record from './tables/record' import * as notification from './tables/notification' import * as notificationPushToken from './tables/notification-push-token' -import * as moderation from './tables/moderation' +import * as didCache from './tables/did-cache' import * as label from './tables/label' import * as algo from './tables/algo' import * as viewParam from './tables/view-param' @@ -32,6 +32,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 & @@ -58,14 +59,15 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB & record.PartialDB & notification.PartialDB & notificationPushToken.PartialDB & - moderation.PartialDB & + didCache.PartialDB & label.PartialDB & algo.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/db/primary.ts b/packages/bsky/src/data-plane/server/db/db.ts similarity index 54% rename from packages/bsky/src/db/primary.ts rename to packages/bsky/src/data-plane/server/db/db.ts index e6e69872fd5..9b1cc645d62 100644 --- a/packages/bsky/src/db/primary.ts +++ b/packages/bsky/src/data-plane/server/db/db.ts @@ -1,35 +1,77 @@ +import assert from 'assert' import EventEmitter from 'events' import { - Migrator, + Kysely, KyselyPlugin, + Migrator, PluginTransformQueryArgs, PluginTransformResultArgs, - RootOperationNode, + PostgresDialect, QueryResult, + RootOperationNode, UnknownRow, - sql, } from 'kysely' -import { Pool as PgPool } from 'pg' import TypedEmitter from 'typed-emitter' -import { wait } from '@atproto/common' -import DatabaseSchema from './database-schema' +import { Pool as PgPool, types as pgTypes } from 'pg' import * as migrations from './migrations' -import { CtxMigrationProvider } from './migrations/provider' -import { dbLogger as log } from '../logger' +import DatabaseSchema, { DatabaseSchemaType } from './database-schema' import { PgOptions } from './types' -import { Database } from './db' +import { dbLogger } from '../../../logger' +import { CtxMigrationProvider } from './migrations/provider' -export class PrimaryDatabase extends Database { +export class Database { + pool: PgPool + db: DatabaseSchema migrator: Migrator txEvt = new EventEmitter() as TxnEmitter destroyed = false - isPrimary = true constructor( public opts: PgOptions, - instances?: { db: DatabaseSchema; pool: PgPool }, + instances?: { db: DatabaseSchema; pool: PgPool; migrator: Migrator }, ) { - super(opts, instances) + // if instances are provided, use those + if (instances) { + this.db = instances.db + this.pool = instances.pool + this.migrator = instances.migrator + return + } + + // else create a pool & connect + const { schema, url } = opts + const pool = + opts.pool ?? + new PgPool({ + connectionString: url, + max: opts.poolSize, + maxUses: opts.poolMaxUses, + idleTimeoutMillis: opts.poolIdleTimeoutMs, + }) + + // Select count(*) and other pg bigints as js integer + pgTypes.setTypeParser(pgTypes.builtins.INT8, (n) => parseInt(n, 10)) + + // Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema) + if (schema && !/^[a-z_]+$/i.test(schema)) { + throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`) + } + + pool.on('error', onPoolError) + pool.on('connect', (client) => { + client.on('error', onClientError) + // Used for trigram indexes, e.g. on actor search + client.query('SET pg_trgm.word_similarity_threshold TO .4;') + if (schema) { + // Shared objects such as extensions will go in the public schema + client.query(`SET search_path TO "${schema}",public;`) + } + }) + + this.pool = pool + this.db = new Kysely({ + dialect: new PostgresDialect({ pool }), + }) this.migrator = new Migrator({ db: this.db, migrationTableSchema: opts.schema, @@ -37,23 +79,20 @@ export class PrimaryDatabase extends Database { }) } - static is(db: Database): db is PrimaryDatabase { - return db.isPrimary - } - - asPrimary(): PrimaryDatabase { - return this + get schema(): string | undefined { + return this.opts.schema } - async transaction(fn: (db: PrimaryDatabase) => Promise): Promise { + async transaction(fn: (db: Database) => Promise): Promise { const leakyTxPlugin = new LeakyTxPlugin() const { dbTxn, txRes } = await this.db .withPlugin(leakyTxPlugin) .transaction() .execute(async (txn) => { - const dbTxn = new PrimaryDatabase(this.opts, { + const dbTxn = new Database(this.opts, { db: txn, pool: this.pool, + migrator: this.migrator, }) const txRes = await fn(dbTxn) .catch(async (err) => { @@ -69,17 +108,23 @@ export class PrimaryDatabase extends Database { return txRes } + get isTransaction() { + return this.db.isTransaction + } + + assertTransaction() { + assert(this.isTransaction, 'Transaction required') + } + + assertNotTransaction() { + assert(!this.isTransaction, 'Cannot be in a transaction') + } + onCommit(fn: () => void) { this.assertTransaction() this.txEvt.once('commit', fn) } - async close(): Promise { - if (this.destroyed) return - await this.db.destroy() - this.destroyed = true - } - async migrateToOrThrow(migration: string) { if (this.schema) { await this.db.schema.createSchema(this.schema).ifNotExists().execute() @@ -108,54 +153,23 @@ export class PrimaryDatabase extends Database { return results } - async maintainMaterializedViews(opts: { - views: string[] - intervalSec: number - signal: AbortSignal - }) { - const { views, intervalSec, signal } = opts - while (!signal.aborted) { - // super basic synchronization by agreeing when the intervals land relative to unix timestamp - const now = Date.now() - const intervalMs = 1000 * intervalSec - const nextIteration = Math.ceil(now / intervalMs) - const nextInMs = nextIteration * intervalMs - now - await wait(nextInMs) - if (signal.aborted) break - await Promise.all( - views.map(async (view) => { - try { - await this.refreshMaterializedView(view) - log.info( - { view, time: new Date().toISOString() }, - 'materialized view refreshed', - ) - } catch (err) { - log.error( - { view, err, time: new Date().toISOString() }, - 'materialized view refresh failed', - ) - } - }), - ) - } - } - - async refreshMaterializedView(view: string) { - const { ref } = this.db.dynamic - await sql`refresh materialized view concurrently ${ref(view)}`.execute( - this.db, - ) + async close(): Promise { + if (this.destroyed) return + await this.db.destroy() + this.destroyed = true } } -export default PrimaryDatabase +export default Database + +const onPoolError = (err: Error) => dbLogger.error({ err }, 'db pool error') +const onClientError = (err: Error) => dbLogger.error({ err }, 'db client error') // utils // ------- class LeakyTxPlugin implements KyselyPlugin { - private txOver: boolean + private txOver = false endTx() { this.txOver = true diff --git a/packages/bsky/src/data-plane/server/db/index.ts b/packages/bsky/src/data-plane/server/db/index.ts new file mode 100644 index 00000000000..1beb455f5e3 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/index.ts @@ -0,0 +1 @@ +export * from './db' diff --git a/packages/bsky/src/db/migrations/20230309T045948368Z-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230309T045948368Z-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230309T045948368Z-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230309T045948368Z-init.ts diff --git a/packages/bsky/src/db/migrations/20230408T152211201Z-notification-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230408T152211201Z-notification-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230408T152211201Z-notification-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230408T152211201Z-notification-init.ts diff --git a/packages/bsky/src/db/migrations/20230417T210628672Z-moderation-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230417T210628672Z-moderation-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230417T210628672Z-moderation-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230417T210628672Z-moderation-init.ts diff --git a/packages/bsky/src/db/migrations/20230420T211446071Z-did-cache.ts b/packages/bsky/src/data-plane/server/db/migrations/20230420T211446071Z-did-cache.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230420T211446071Z-did-cache.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230420T211446071Z-did-cache.ts diff --git a/packages/bsky/src/db/migrations/20230427T194702079Z-notif-record-index.ts b/packages/bsky/src/data-plane/server/db/migrations/20230427T194702079Z-notif-record-index.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230427T194702079Z-notif-record-index.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230427T194702079Z-notif-record-index.ts diff --git a/packages/bsky/src/db/migrations/20230605T144730094Z-post-profile-aggs.ts b/packages/bsky/src/data-plane/server/db/migrations/20230605T144730094Z-post-profile-aggs.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230605T144730094Z-post-profile-aggs.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230605T144730094Z-post-profile-aggs.ts diff --git a/packages/bsky/src/db/migrations/20230607T211442112Z-feed-generator-init.ts b/packages/bsky/src/data-plane/server/db/migrations/20230607T211442112Z-feed-generator-init.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230607T211442112Z-feed-generator-init.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230607T211442112Z-feed-generator-init.ts diff --git a/packages/bsky/src/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T155101190Z-algo-whats-hot-view.ts diff --git a/packages/bsky/src/db/migrations/20230608T201813132Z-mute-lists.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T201813132Z-mute-lists.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T201813132Z-mute-lists.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T201813132Z-mute-lists.ts diff --git a/packages/bsky/src/db/migrations/20230608T205147239Z-mutes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230608T205147239Z-mutes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230608T205147239Z-mutes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230608T205147239Z-mutes.ts diff --git a/packages/bsky/src/db/migrations/20230609T153623961Z-blocks.ts b/packages/bsky/src/data-plane/server/db/migrations/20230609T153623961Z-blocks.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230609T153623961Z-blocks.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230609T153623961Z-blocks.ts diff --git a/packages/bsky/src/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230609T232122649Z-actor-deletion-indexes.ts diff --git a/packages/bsky/src/db/migrations/20230610T203555962Z-suggested-follows.ts b/packages/bsky/src/data-plane/server/db/migrations/20230610T203555962Z-suggested-follows.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230610T203555962Z-suggested-follows.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230610T203555962Z-suggested-follows.ts diff --git a/packages/bsky/src/db/migrations/20230611T215300060Z-actor-state.ts b/packages/bsky/src/data-plane/server/db/migrations/20230611T215300060Z-actor-state.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230611T215300060Z-actor-state.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230611T215300060Z-actor-state.ts diff --git a/packages/bsky/src/db/migrations/20230620T161134972Z-post-langs.ts b/packages/bsky/src/data-plane/server/db/migrations/20230620T161134972Z-post-langs.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230620T161134972Z-post-langs.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230620T161134972Z-post-langs.ts diff --git a/packages/bsky/src/db/migrations/20230627T212437895Z-optional-handle.ts b/packages/bsky/src/data-plane/server/db/migrations/20230627T212437895Z-optional-handle.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230627T212437895Z-optional-handle.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230627T212437895Z-optional-handle.ts diff --git a/packages/bsky/src/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts b/packages/bsky/src/data-plane/server/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230629T220835893Z-remove-post-hierarchy.ts diff --git a/packages/bsky/src/db/migrations/20230703T045536691Z-feed-and-label-indices.ts b/packages/bsky/src/data-plane/server/db/migrations/20230703T045536691Z-feed-and-label-indices.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230703T045536691Z-feed-and-label-indices.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230703T045536691Z-feed-and-label-indices.ts diff --git a/packages/bsky/src/db/migrations/20230720T164800037Z-posts-cursor-idx.ts b/packages/bsky/src/data-plane/server/db/migrations/20230720T164800037Z-posts-cursor-idx.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230720T164800037Z-posts-cursor-idx.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230720T164800037Z-posts-cursor-idx.ts diff --git a/packages/bsky/src/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts b/packages/bsky/src/data-plane/server/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230807T035309811Z-feed-item-delete-invite-for-user-idx.ts diff --git a/packages/bsky/src/db/migrations/20230808T172902639Z-repo-rev.ts b/packages/bsky/src/data-plane/server/db/migrations/20230808T172902639Z-repo-rev.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230808T172902639Z-repo-rev.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230808T172902639Z-repo-rev.ts diff --git a/packages/bsky/src/db/migrations/20230810T203349843Z-action-duration.ts b/packages/bsky/src/data-plane/server/db/migrations/20230810T203349843Z-action-duration.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230810T203349843Z-action-duration.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230810T203349843Z-action-duration.ts diff --git a/packages/bsky/src/db/migrations/20230817T195936007Z-native-notifications.ts b/packages/bsky/src/data-plane/server/db/migrations/20230817T195936007Z-native-notifications.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230817T195936007Z-native-notifications.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230817T195936007Z-native-notifications.ts diff --git a/packages/bsky/src/db/migrations/20230830T205507322Z-suggested-feeds.ts b/packages/bsky/src/data-plane/server/db/migrations/20230830T205507322Z-suggested-feeds.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230830T205507322Z-suggested-feeds.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230830T205507322Z-suggested-feeds.ts diff --git a/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts b/packages/bsky/src/data-plane/server/db/migrations/20230904T211011773Z-block-lists.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230904T211011773Z-block-lists.ts diff --git a/packages/bsky/src/db/migrations/20230906T222220386Z-thread-gating.ts b/packages/bsky/src/data-plane/server/db/migrations/20230906T222220386Z-thread-gating.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230906T222220386Z-thread-gating.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230906T222220386Z-thread-gating.ts diff --git a/packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts b/packages/bsky/src/data-plane/server/db/migrations/20230920T213858047Z-add-tags-to-post.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230920T213858047Z-add-tags-to-post.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230920T213858047Z-add-tags-to-post.ts diff --git a/packages/bsky/src/db/migrations/20230929T192920807Z-record-cursor-indexes.ts b/packages/bsky/src/data-plane/server/db/migrations/20230929T192920807Z-record-cursor-indexes.ts similarity index 100% rename from packages/bsky/src/db/migrations/20230929T192920807Z-record-cursor-indexes.ts rename to packages/bsky/src/data-plane/server/db/migrations/20230929T192920807Z-record-cursor-indexes.ts diff --git a/packages/bsky/src/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts b/packages/bsky/src/data-plane/server/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts similarity index 100% rename from packages/bsky/src/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts rename to packages/bsky/src/data-plane/server/db/migrations/20231003T202833377Z-create-moderation-subject-status.ts diff --git a/packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts b/packages/bsky/src/data-plane/server/db/migrations/20231220T225126090Z-blob-takedowns.ts similarity index 100% rename from packages/bsky/src/db/migrations/20231220T225126090Z-blob-takedowns.ts rename to packages/bsky/src/data-plane/server/db/migrations/20231220T225126090Z-blob-takedowns.ts diff --git a/packages/bsky/src/db/migrations/20240124T023719200Z-tagged-suggestions.ts b/packages/bsky/src/data-plane/server/db/migrations/20240124T023719200Z-tagged-suggestions.ts similarity index 100% rename from packages/bsky/src/db/migrations/20240124T023719200Z-tagged-suggestions.ts rename to packages/bsky/src/data-plane/server/db/migrations/20240124T023719200Z-tagged-suggestions.ts 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/db/migrations/index.ts b/packages/bsky/src/data-plane/server/db/migrations/index.ts similarity index 97% rename from packages/bsky/src/db/migrations/index.ts rename to packages/bsky/src/data-plane/server/db/migrations/index.ts index e5a5b155e71..0c1e7fe2c05 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/data-plane/server/db/migrations/index.ts @@ -31,6 +31,6 @@ export * as _20230906T222220386Z from './20230906T222220386Z-thread-gating' export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post' export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes' export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status' -export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache' 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/db/migrations/provider.ts b/packages/bsky/src/data-plane/server/db/migrations/provider.ts similarity index 100% rename from packages/bsky/src/db/migrations/provider.ts rename to packages/bsky/src/data-plane/server/db/migrations/provider.ts diff --git a/packages/bsky/src/db/pagination.ts b/packages/bsky/src/data-plane/server/db/pagination.ts similarity index 88% rename from packages/bsky/src/db/pagination.ts rename to packages/bsky/src/data-plane/server/db/pagination.ts index f08702cb003..d4c5747e2b4 100644 --- a/packages/bsky/src/db/pagination.ts +++ b/packages/bsky/src/data-plane/server/db/pagination.ts @@ -16,7 +16,7 @@ export type LabeledResult = { * - LabeledResult: a Result processed such that the "primary" and "secondary" parts of the cursor are labeled. * - E.g. { primary: '2022-01-01T12:00:00Z', secondary: 'bafyx' } * - Cursor: the two string parts that make-up the packed/string cursor. - * - E.g. packed cursor '1641038400000::bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' } + * - E.g. packed cursor '1641038400000__bafyx' in parts { primary: '1641038400000', secondary: 'bafyx' } * * These types relate as such. Implementers define the relations marked with a *: * Result -*-> LabeledResult <-*-> Cursor <--> packed/string cursor @@ -27,9 +27,6 @@ export abstract class GenericKeyset { abstract labelResult(result: R): LR abstract labeledResultToCursor(labeled: LR): Cursor abstract cursorToLabeledResult(cursor: Cursor): LR - static clearlyBad(cursor?: string) { - return cursor !== undefined && !cursor.includes('::') - } packFromResult(results: R | R[]): string | undefined { const result = Array.isArray(results) ? results.at(-1) : results if (!result) return @@ -47,11 +44,11 @@ export abstract class GenericKeyset { } packCursor(cursor?: Cursor): string | undefined { if (!cursor) return - return `${cursor.primary}::${cursor.secondary}` + return `${cursor.primary}__${cursor.secondary}` } unpackCursor(cursorStr?: string): Cursor | undefined { if (!cursorStr) return - const result = cursorStr.split('::') + const result = cursorStr.split('__') const [primary, secondary, ...others] = result if (!primary || !secondary || others.length > 0) { throw new InvalidRequestError('Malformed cursor') @@ -109,6 +106,24 @@ export class TimeCidKeyset< } } +export class CreatedAtDidKeyset extends TimeCidKeyset<{ + createdAt: string + did: string // dids are treated identically to cids in TimeCidKeyset +}> { + labelResult(result: { createdAt: string; did: string }) { + return { primary: result.createdAt, secondary: result.did } + } +} + +export class IndexedAtDidKeyset extends TimeCidKeyset<{ + indexedAt: string + did: string // dids are treated identically to cids in TimeCidKeyset +}> { + labelResult(result: { indexedAt: string; did: string }) { + return { primary: result.indexedAt, secondary: result.did } + } +} + export const paginate = < QB extends AnyQb, K extends GenericKeyset, diff --git a/packages/bsky/src/db/tables/actor-block.ts b/packages/bsky/src/data-plane/server/db/tables/actor-block.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-block.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-block.ts diff --git a/packages/bsky/src/db/tables/actor-state.ts b/packages/bsky/src/data-plane/server/db/tables/actor-state.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-state.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-state.ts diff --git a/packages/bsky/src/db/tables/actor-sync.ts b/packages/bsky/src/data-plane/server/db/tables/actor-sync.ts similarity index 100% rename from packages/bsky/src/db/tables/actor-sync.ts rename to packages/bsky/src/data-plane/server/db/tables/actor-sync.ts diff --git a/packages/bsky/src/db/tables/actor.ts b/packages/bsky/src/data-plane/server/db/tables/actor.ts similarity index 100% rename from packages/bsky/src/db/tables/actor.ts rename to packages/bsky/src/data-plane/server/db/tables/actor.ts diff --git a/packages/bsky/src/db/tables/algo.ts b/packages/bsky/src/data-plane/server/db/tables/algo.ts similarity index 100% rename from packages/bsky/src/db/tables/algo.ts rename to packages/bsky/src/data-plane/server/db/tables/algo.ts diff --git a/packages/bsky/src/db/tables/blob-takedown.ts b/packages/bsky/src/data-plane/server/db/tables/blob-takedown.ts similarity index 100% rename from packages/bsky/src/db/tables/blob-takedown.ts rename to packages/bsky/src/data-plane/server/db/tables/blob-takedown.ts diff --git a/packages/bsky/src/data-plane/server/db/tables/did-cache.ts b/packages/bsky/src/data-plane/server/db/tables/did-cache.ts new file mode 100644 index 00000000000..b3865548725 --- /dev/null +++ b/packages/bsky/src/data-plane/server/db/tables/did-cache.ts @@ -0,0 +1,13 @@ +import { DidDocument } from '@atproto/identity' + +export interface DidCache { + did: string + doc: DidDocument + updatedAt: number +} + +export const tableName = 'did_cache' + +export type PartialDB = { + [tableName]: DidCache +} diff --git a/packages/bsky/src/db/tables/duplicate-record.ts b/packages/bsky/src/data-plane/server/db/tables/duplicate-record.ts similarity index 100% rename from packages/bsky/src/db/tables/duplicate-record.ts rename to packages/bsky/src/data-plane/server/db/tables/duplicate-record.ts diff --git a/packages/bsky/src/db/tables/feed-generator.ts b/packages/bsky/src/data-plane/server/db/tables/feed-generator.ts similarity index 100% rename from packages/bsky/src/db/tables/feed-generator.ts rename to packages/bsky/src/data-plane/server/db/tables/feed-generator.ts diff --git a/packages/bsky/src/db/tables/feed-item.ts b/packages/bsky/src/data-plane/server/db/tables/feed-item.ts similarity index 100% rename from packages/bsky/src/db/tables/feed-item.ts rename to packages/bsky/src/data-plane/server/db/tables/feed-item.ts diff --git a/packages/bsky/src/db/tables/follow.ts b/packages/bsky/src/data-plane/server/db/tables/follow.ts similarity index 100% rename from packages/bsky/src/db/tables/follow.ts rename to packages/bsky/src/data-plane/server/db/tables/follow.ts diff --git a/packages/bsky/src/db/tables/label.ts b/packages/bsky/src/data-plane/server/db/tables/label.ts similarity index 100% rename from packages/bsky/src/db/tables/label.ts rename to packages/bsky/src/data-plane/server/db/tables/label.ts 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/db/tables/like.ts b/packages/bsky/src/data-plane/server/db/tables/like.ts similarity index 100% rename from packages/bsky/src/db/tables/like.ts rename to packages/bsky/src/data-plane/server/db/tables/like.ts diff --git a/packages/bsky/src/db/tables/list-block.ts b/packages/bsky/src/data-plane/server/db/tables/list-block.ts similarity index 100% rename from packages/bsky/src/db/tables/list-block.ts rename to packages/bsky/src/data-plane/server/db/tables/list-block.ts diff --git a/packages/bsky/src/db/tables/list-item.ts b/packages/bsky/src/data-plane/server/db/tables/list-item.ts similarity index 100% rename from packages/bsky/src/db/tables/list-item.ts rename to packages/bsky/src/data-plane/server/db/tables/list-item.ts diff --git a/packages/bsky/src/db/tables/list-mute.ts b/packages/bsky/src/data-plane/server/db/tables/list-mute.ts similarity index 100% rename from packages/bsky/src/db/tables/list-mute.ts rename to packages/bsky/src/data-plane/server/db/tables/list-mute.ts diff --git a/packages/bsky/src/db/tables/list.ts b/packages/bsky/src/data-plane/server/db/tables/list.ts similarity index 100% rename from packages/bsky/src/db/tables/list.ts rename to packages/bsky/src/data-plane/server/db/tables/list.ts diff --git a/packages/bsky/src/db/tables/mute.ts b/packages/bsky/src/data-plane/server/db/tables/mute.ts similarity index 100% rename from packages/bsky/src/db/tables/mute.ts rename to packages/bsky/src/data-plane/server/db/tables/mute.ts diff --git a/packages/bsky/src/db/tables/notification-push-token.ts b/packages/bsky/src/data-plane/server/db/tables/notification-push-token.ts similarity index 100% rename from packages/bsky/src/db/tables/notification-push-token.ts rename to packages/bsky/src/data-plane/server/db/tables/notification-push-token.ts diff --git a/packages/bsky/src/db/tables/notification.ts b/packages/bsky/src/data-plane/server/db/tables/notification.ts similarity index 100% rename from packages/bsky/src/db/tables/notification.ts rename to packages/bsky/src/data-plane/server/db/tables/notification.ts diff --git a/packages/bsky/src/db/tables/post-agg.ts b/packages/bsky/src/data-plane/server/db/tables/post-agg.ts similarity index 100% rename from packages/bsky/src/db/tables/post-agg.ts rename to packages/bsky/src/data-plane/server/db/tables/post-agg.ts diff --git a/packages/bsky/src/db/tables/post-embed.ts b/packages/bsky/src/data-plane/server/db/tables/post-embed.ts similarity index 100% rename from packages/bsky/src/db/tables/post-embed.ts rename to packages/bsky/src/data-plane/server/db/tables/post-embed.ts diff --git a/packages/bsky/src/db/tables/post.ts b/packages/bsky/src/data-plane/server/db/tables/post.ts similarity index 100% rename from packages/bsky/src/db/tables/post.ts rename to packages/bsky/src/data-plane/server/db/tables/post.ts diff --git a/packages/bsky/src/db/tables/profile-agg.ts b/packages/bsky/src/data-plane/server/db/tables/profile-agg.ts similarity index 100% rename from packages/bsky/src/db/tables/profile-agg.ts rename to packages/bsky/src/data-plane/server/db/tables/profile-agg.ts diff --git a/packages/bsky/src/db/tables/profile.ts b/packages/bsky/src/data-plane/server/db/tables/profile.ts similarity index 100% rename from packages/bsky/src/db/tables/profile.ts rename to packages/bsky/src/data-plane/server/db/tables/profile.ts diff --git a/packages/bsky/src/db/tables/record.ts b/packages/bsky/src/data-plane/server/db/tables/record.ts similarity index 100% rename from packages/bsky/src/db/tables/record.ts rename to packages/bsky/src/data-plane/server/db/tables/record.ts diff --git a/packages/bsky/src/db/tables/repost.ts b/packages/bsky/src/data-plane/server/db/tables/repost.ts similarity index 100% rename from packages/bsky/src/db/tables/repost.ts rename to packages/bsky/src/data-plane/server/db/tables/repost.ts diff --git a/packages/bsky/src/db/tables/subscription.ts b/packages/bsky/src/data-plane/server/db/tables/subscription.ts similarity index 100% rename from packages/bsky/src/db/tables/subscription.ts rename to packages/bsky/src/data-plane/server/db/tables/subscription.ts diff --git a/packages/bsky/src/db/tables/suggested-feed.ts b/packages/bsky/src/data-plane/server/db/tables/suggested-feed.ts similarity index 100% rename from packages/bsky/src/db/tables/suggested-feed.ts rename to packages/bsky/src/data-plane/server/db/tables/suggested-feed.ts diff --git a/packages/bsky/src/db/tables/suggested-follow.ts b/packages/bsky/src/data-plane/server/db/tables/suggested-follow.ts similarity index 100% rename from packages/bsky/src/db/tables/suggested-follow.ts rename to packages/bsky/src/data-plane/server/db/tables/suggested-follow.ts diff --git a/packages/bsky/src/db/tables/tagged-suggestion.ts b/packages/bsky/src/data-plane/server/db/tables/tagged-suggestion.ts similarity index 100% rename from packages/bsky/src/db/tables/tagged-suggestion.ts rename to packages/bsky/src/data-plane/server/db/tables/tagged-suggestion.ts diff --git a/packages/bsky/src/db/tables/thread-gate.ts b/packages/bsky/src/data-plane/server/db/tables/thread-gate.ts similarity index 100% rename from packages/bsky/src/db/tables/thread-gate.ts rename to packages/bsky/src/data-plane/server/db/tables/thread-gate.ts diff --git a/packages/bsky/src/db/tables/view-param.ts b/packages/bsky/src/data-plane/server/db/tables/view-param.ts similarity index 100% rename from packages/bsky/src/db/tables/view-param.ts rename to packages/bsky/src/data-plane/server/db/tables/view-param.ts diff --git a/packages/bsky/src/db/types.ts b/packages/bsky/src/data-plane/server/db/types.ts similarity index 100% rename from packages/bsky/src/db/types.ts rename to packages/bsky/src/data-plane/server/db/types.ts diff --git a/packages/bsky/src/db/util.ts b/packages/bsky/src/data-plane/server/db/util.ts similarity index 100% rename from packages/bsky/src/db/util.ts rename to packages/bsky/src/data-plane/server/db/util.ts diff --git a/packages/bsky/src/data-plane/server/index.ts b/packages/bsky/src/data-plane/server/index.ts new file mode 100644 index 00000000000..f925de83c48 --- /dev/null +++ b/packages/bsky/src/data-plane/server/index.ts @@ -0,0 +1,36 @@ +import http from 'http' +import events from 'events' +import express from 'express' +import { expressConnectMiddleware } from '@connectrpc/connect-express' +import createRoutes from './routes' +import { Database } from './db' +import { IdResolver, MemoryCache } from '@atproto/identity' + +export { RepoSubscription } from './subscription' + +export class DataPlaneServer { + constructor(public server: http.Server, public idResolver: IdResolver) {} + + static async create(db: Database, port: number, plcUrl?: string) { + const app = express() + const didCache = new MemoryCache() + const idResolver = new IdResolver({ plcUrl, didCache }) + const routes = createRoutes(db, idResolver) + app.use(expressConnectMiddleware({ routes })) + const server = app.listen(port) + await events.once(server, 'listening') + return new DataPlaneServer(server, idResolver) + } + + async destroy() { + return new Promise((resolve, reject) => { + this.server.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } +} diff --git a/packages/bsky/src/services/indexing/index.ts b/packages/bsky/src/data-plane/server/indexing/index.ts similarity index 86% rename from packages/bsky/src/services/indexing/index.ts rename to packages/bsky/src/data-plane/server/indexing/index.ts index 44dd9c3c986..743e0380f97 100644 --- a/packages/bsky/src/services/indexing/index.ts +++ b/packages/bsky/src/data-plane/server/indexing/index.ts @@ -13,7 +13,8 @@ import { AtUri } from '@atproto/syntax' import { IdResolver, getPds } from '@atproto/identity' import { DAY, HOUR } from '@atproto/common' import { ValidationError } from '@atproto/lexicon' -import { PrimaryDatabase } from '../../db' +import { Database } from '../db' +import { Actor } from '../db/tables/actor' import * as Post from './plugins/post' import * as Threadgate from './plugins/thread-gate' import * as Like from './plugins/like' @@ -25,13 +26,11 @@ 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' -import { BackgroundQueue } from '../../background' -import { NotificationServer } from '../../notifications' -import { AutoModerator } from '../../auto-moderator' -import { Actor } from '../../db/tables/actor' +import { subLogger } from '../../../logger' +import { retryHttp } from '../../../util/retry' +import { BackgroundQueue } from '../background' export class IndexingService { records: { @@ -46,53 +45,33 @@ export class IndexingService { listBlock: ListBlock.PluginType block: Block.PluginType feedGenerator: FeedGenerator.PluginType + labeler: Labeler.PluginType } constructor( - public db: PrimaryDatabase, + public db: Database, public idResolver: IdResolver, - public autoMod: AutoModerator, - public backgroundQueue: BackgroundQueue, - public notifServer?: NotificationServer, + public background: BackgroundQueue, ) { this.records = { - post: Post.makePlugin(this.db, backgroundQueue, notifServer), - threadGate: Threadgate.makePlugin(this.db, backgroundQueue, notifServer), - like: Like.makePlugin(this.db, backgroundQueue, notifServer), - repost: Repost.makePlugin(this.db, backgroundQueue, notifServer), - follow: Follow.makePlugin(this.db, backgroundQueue, notifServer), - profile: Profile.makePlugin(this.db, backgroundQueue, notifServer), - list: List.makePlugin(this.db, backgroundQueue, notifServer), - listItem: ListItem.makePlugin(this.db, backgroundQueue, notifServer), - listBlock: ListBlock.makePlugin(this.db, backgroundQueue, notifServer), - block: Block.makePlugin(this.db, backgroundQueue, notifServer), - feedGenerator: FeedGenerator.makePlugin( - this.db, - backgroundQueue, - notifServer, - ), + post: Post.makePlugin(this.db, this.background), + threadGate: Threadgate.makePlugin(this.db, this.background), + like: Like.makePlugin(this.db, this.background), + repost: Repost.makePlugin(this.db, this.background), + follow: Follow.makePlugin(this.db, this.background), + profile: Profile.makePlugin(this.db, this.background), + list: List.makePlugin(this.db, this.background), + listItem: ListItem.makePlugin(this.db, this.background), + 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), } } - transact(txn: PrimaryDatabase) { + transact(txn: Database) { txn.assertTransaction() - return new IndexingService( - txn, - this.idResolver, - this.autoMod, - this.backgroundQueue, - this.notifServer, - ) - } - - static creator( - idResolver: IdResolver, - autoMod: AutoModerator, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, - ) { - return (db: PrimaryDatabase) => - new IndexingService(db, idResolver, autoMod, backgroundQueue, notifServer) + return new IndexingService(txn, this.idResolver, this.background) } async indexRecord( @@ -114,9 +93,6 @@ export class IndexingService { await indexer.updateRecord(uri, cid, obj, timestamp) } }) - if (!opts?.disableLabels) { - this.autoMod.processRecord(uri, cid, obj) - } } async deleteRecord(uri: AtUri, cascading = false) { @@ -170,10 +146,6 @@ export class IndexingService { .onConflict((oc) => oc.column('did').doUpdateSet(actorInfo)) .returning('did') .executeTakeFirst() - - if (handle) { - this.autoMod.processHandle(handle, did) - } } async indexRepo(did: string, commit?: string) { @@ -329,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/services/indexing/plugins/block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/block.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/block.ts index 88e62b6f5ac..ec4956a04f5 100644 --- a/packages/bsky/src/services/indexing/plugins/block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Block from '../../../lexicon/types/app/bsky/graph/block' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Block from '../../../../lexicon/types/app/bsky/graph/block' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphBlock type IndexedBlock = Selectable @@ -72,11 +71,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/feed-generator.ts b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/feed-generator.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts index be5435966f1..f3b82c75567 100644 --- a/packages/bsky/src/services/indexing/plugins/feed-generator.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as FeedGenerator from '../../../lexicon/types/app/bsky/feed/generator' -import * as lex from '../../../lexicon/lexicons' -import { PrimaryDatabase } from '../../../db' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' -import { BackgroundQueue } from '../../../background' +import * as FeedGenerator from '../../../../lexicon/types/app/bsky/feed/generator' +import * as lex from '../../../../lexicon/lexicons' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedGenerator type IndexedFeedGenerator = Selectable @@ -71,11 +70,10 @@ export type PluginType = RecordProcessor< > export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/follow.ts b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts similarity index 84% rename from packages/bsky/src/services/indexing/plugins/follow.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/follow.ts index 8655c7eba71..6f238755761 100644 --- a/packages/bsky/src/services/indexing/plugins/follow.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Follow from '../../../lexicon/types/app/bsky/graph/follow' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Follow from '../../../../lexicon/types/app/bsky/graph/follow' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphFollow type IndexedFollow = Selectable @@ -119,11 +118,10 @@ const updateAggregates = async (db: DatabaseSchema, follow: IndexedFollow) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, 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/services/indexing/plugins/like.ts b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts similarity index 83% rename from packages/bsky/src/services/indexing/plugins/like.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/like.ts index 703800f67c8..98e9fc722f8 100644 --- a/packages/bsky/src/services/indexing/plugins/like.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Like from '../../../lexicon/types/app/bsky/feed/like' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Like from '../../../../lexicon/types/app/bsky/feed/like' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { countAll, excluded } from '../../../db/util' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { countAll, excluded } from '../../db/util' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedLike type IndexedLike = Selectable @@ -109,11 +108,10 @@ const updateAggregates = async (db: DatabaseSchema, like: IndexedLike) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list-block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/list-block.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts index 3040f1aa3f9..09eabcdb9f4 100644 --- a/packages/bsky/src/services/indexing/plugins/list-block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as ListBlock from '../../../lexicon/types/app/bsky/graph/listblock' -import * as lex from '../../../lexicon/lexicons' -import { PrimaryDatabase } from '../../../db' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as ListBlock from '../../../../lexicon/types/app/bsky/graph/listblock' +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' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphListblock type IndexedListBlock = Selectable @@ -72,11 +71,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list-item.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts similarity index 79% rename from packages/bsky/src/services/indexing/plugins/list-item.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts index 9e08145b23e..f2a43cff485 100644 --- a/packages/bsky/src/services/indexing/plugins/list-item.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as ListItem from '../../../lexicon/types/app/bsky/graph/listitem' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as ListItem from '../../../../lexicon/types/app/bsky/graph/listitem' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphListitem type IndexedListItem = Selectable @@ -80,11 +79,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/list.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts similarity index 77% rename from packages/bsky/src/services/indexing/plugins/list.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/list.ts index 0d078572501..f6deaf0a68e 100644 --- a/packages/bsky/src/services/indexing/plugins/list.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts @@ -1,13 +1,12 @@ import { Selectable } from 'kysely' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as List from '../../../lexicon/types/app/bsky/graph/list' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as List from '../../../../lexicon/types/app/bsky/graph/list' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyGraphList type IndexedList = Selectable @@ -68,11 +67,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/post.ts b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts similarity index 89% rename from packages/bsky/src/services/indexing/plugins/post.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/post.ts index af581b3bdff..cc4121ab667 100644 --- a/packages/bsky/src/services/indexing/plugins/post.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts @@ -5,27 +5,30 @@ import { jsonStringToLex } from '@atproto/lexicon' import { Record as PostRecord, ReplyRef, -} from '../../../lexicon/types/app/bsky/feed/post' -import { Record as GateRecord } from '../../../lexicon/types/app/bsky/feed/threadgate' -import { isMain as isEmbedImage } from '../../../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedExternal } from '../../../lexicon/types/app/bsky/embed/external' -import { isMain as isEmbedRecord } from '../../../lexicon/types/app/bsky/embed/record' -import { isMain as isEmbedRecordWithMedia } from '../../../lexicon/types/app/bsky/embed/recordWithMedia' +} from '../../../../lexicon/types/app/bsky/feed/post' +import { Record as GateRecord } from '../../../../lexicon/types/app/bsky/feed/threadgate' +import { isMain as isEmbedImage } from '../../../../lexicon/types/app/bsky/embed/images' +import { isMain as isEmbedExternal } from '../../../../lexicon/types/app/bsky/embed/external' +import { isMain as isEmbedRecord } from '../../../../lexicon/types/app/bsky/embed/record' +import { isMain as isEmbedRecordWithMedia } from '../../../../lexicon/types/app/bsky/embed/recordWithMedia' import { isMention, isLink, -} from '../../../lexicon/types/app/bsky/richtext/facet' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +} from '../../../../lexicon/types/app/bsky/richtext/facet' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { Notification } from '../../../db/tables/notification' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { getAncestorsAndSelfQb, getDescendentsQb } from '../../util/post' -import { NotificationServer } from '../../../notifications' -import * as feedutil from '../../feed/util' -import { postToThreadgateUri } from '../../feed/util' +import { Notification } from '../../db/tables/notification' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { + getAncestorsAndSelfQb, + getDescendentsQb, + invalidReplyRoot as checkInvalidReplyRoot, + violatesThreadGate as checkViolatesThreadGate, + postToThreadgateUri, +} from '../../util' +import { BackgroundQueue } from '../../background' type Notif = Insertable type Post = Selectable @@ -392,11 +395,10 @@ const updateAggregates = async (db: DatabaseSchema, postIdx: IndexedPost) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, @@ -427,9 +429,9 @@ async function validateReply( const replyRefs = await getReplyRefs(db, reply) // check reply const invalidReplyRoot = - !replyRefs.parent || feedutil.invalidReplyRoot(reply, replyRefs.parent) + !replyRefs.parent || checkInvalidReplyRoot(reply, replyRefs.parent) // check interaction - const violatesThreadGate = await feedutil.violatesThreadGate( + const violatesThreadGate = await checkViolatesThreadGate( db, creator, new AtUri(reply.root.uri).hostname, diff --git a/packages/bsky/src/services/indexing/plugins/profile.ts b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts similarity index 75% rename from packages/bsky/src/services/indexing/plugins/profile.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/profile.ts index ea0c8f07f98..18c9b54bbb9 100644 --- a/packages/bsky/src/services/indexing/plugins/profile.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts @@ -1,12 +1,11 @@ import { AtUri } from '@atproto/syntax' import { CID } from 'multiformats/cid' -import * as Profile from '../../../lexicon/types/app/bsky/actor/profile' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Profile from '../../../../lexicon/types/app/bsky/actor/profile' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyActorProfile type IndexedProfile = DatabaseSchemaType['profile'] @@ -64,11 +63,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/repost.ts b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts similarity index 85% rename from packages/bsky/src/services/indexing/plugins/repost.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/repost.ts index ea8d517dc52..ec2e7754fb0 100644 --- a/packages/bsky/src/services/indexing/plugins/repost.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts @@ -1,14 +1,13 @@ import { Selectable } from 'kysely' import { CID } from 'multiformats/cid' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as Repost from '../../../lexicon/types/app/bsky/feed/repost' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Repost from '../../../../lexicon/types/app/bsky/feed/repost' +import * as lex from '../../../../lexicon/lexicons' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { countAll, excluded } from '../../../db/util' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { Database } from '../../db' +import { countAll, excluded } from '../../db/util' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedRepost type IndexedRepost = Selectable @@ -134,11 +133,10 @@ const updateAggregates = async (db: DatabaseSchema, repost: IndexedRepost) => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/plugins/thread-gate.ts b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts similarity index 79% rename from packages/bsky/src/services/indexing/plugins/thread-gate.ts rename to packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts index 9a58547f2da..0402fe8289f 100644 --- a/packages/bsky/src/services/indexing/plugins/thread-gate.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts @@ -1,13 +1,12 @@ import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { CID } from 'multiformats/cid' -import * as Threadgate from '../../../lexicon/types/app/bsky/feed/threadgate' -import * as lex from '../../../lexicon/lexicons' -import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import * as Threadgate from '../../../../lexicon/types/app/bsky/feed/threadgate' +import * as lex from '../../../../lexicon/lexicons' +import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' +import { Database } from '../../db' import RecordProcessor from '../processor' -import { PrimaryDatabase } from '../../../db' -import { BackgroundQueue } from '../../../background' -import { NotificationServer } from '../../../notifications' +import { BackgroundQueue } from '../../background' const lexId = lex.ids.AppBskyFeedThreadgate type IndexedGate = DatabaseSchemaType['thread_gate'] @@ -77,11 +76,10 @@ const notifsForDelete = () => { export type PluginType = RecordProcessor export const makePlugin = ( - db: PrimaryDatabase, - backgroundQueue: BackgroundQueue, - notifServer?: NotificationServer, + db: Database, + background: BackgroundQueue, ): PluginType => { - return new RecordProcessor(db, backgroundQueue, notifServer, { + return new RecordProcessor(db, background, { lexId, insertFn, findDuplicate, diff --git a/packages/bsky/src/services/indexing/processor.ts b/packages/bsky/src/data-plane/server/indexing/processor.ts similarity index 80% rename from packages/bsky/src/services/indexing/processor.ts rename to packages/bsky/src/data-plane/server/indexing/processor.ts index 0dad405b9ef..77a8fbdf09f 100644 --- a/packages/bsky/src/services/indexing/processor.ts +++ b/packages/bsky/src/data-plane/server/indexing/processor.ts @@ -1,15 +1,13 @@ import { Insertable } from 'kysely' import { CID } from 'multiformats/cid' import { AtUri } from '@atproto/syntax' -import { jsonStringToLex, stringifyLex } from '@atproto/lexicon' -import DatabaseSchema from '../../db/database-schema' -import { lexicons } from '../../lexicon/lexicons' -import { Notification } from '../../db/tables/notification' import { chunkArray } from '@atproto/common' -import { PrimaryDatabase } from '../../db' -import { BackgroundQueue } from '../../background' -import { NotificationServer } from '../../notifications' -import { dbLogger } from '../../logger' +import { jsonStringToLex, stringifyLex } from '@atproto/lexicon' +import { lexicons } from '../../../lexicon/lexicons' +import { Database } from '../db' +import DatabaseSchema from '../db/database-schema' +import { Notification } from '../db/tables/notification' +import { BackgroundQueue } from '../background' // @NOTE re: insertions and deletions. Due to how record updates are handled, // (insertFn) should have the same effect as (insertFn -> deleteFn -> insertFn). @@ -42,9 +40,8 @@ export class RecordProcessor { collection: string db: DatabaseSchema constructor( - private appDb: PrimaryDatabase, - private backgroundQueue: BackgroundQueue, - private notifServer: NotificationServer | undefined, + private appDb: Database, + private background: BackgroundQueue, private params: RecordProcessorParams, ) { this.db = appDb.db @@ -228,8 +225,7 @@ export class RecordProcessor { async handleNotifs(op: { deleted?: S; inserted?: S }) { let notifs: Notif[] = [] - const runOnCommit: ((db: PrimaryDatabase) => Promise)[] = [] - const sendOnCommit: (() => Promise)[] = [] + const runOnCommit: ((db: Database) => Promise)[] = [] if (op.deleted) { const forDelete = this.params.notifsForDelete( op.deleted, @@ -253,37 +249,10 @@ export class RecordProcessor { runOnCommit.push(async (db) => { await db.db.insertInto('notification').values(chunk).execute() }) - if (this.notifServer) { - const notifServer = this.notifServer - sendOnCommit.push(async () => { - try { - const preparedNotifs = await notifServer.prepareNotifications(chunk) - await notifServer.processNotifications(preparedNotifs) - } catch (error) { - dbLogger.error({ error }, 'error sending push notifications') - } - }) - } } - if (runOnCommit.length) { - // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. - this.appDb.onCommit(() => { - this.backgroundQueue.add(async (db) => { - for (const fn of runOnCommit) { - await fn(db) - } - }) - }) - } - if (sendOnCommit.length) { - // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. - this.appDb.onCommit(() => { - this.backgroundQueue.add(async () => { - for (const fn of sendOnCommit) { - await fn() - } - }) - }) + // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. + for (const fn of runOnCommit) { + await fn(this.appDb) // these could be backgrounded } } @@ -291,7 +260,7 @@ export class RecordProcessor { const { updateAggregates } = this.params if (!updateAggregates) return this.appDb.onCommit(() => { - this.backgroundQueue.add((db) => updateAggregates(db.db, indexed)) + this.background.add((db) => updateAggregates(db.db, indexed)) }) } } diff --git a/packages/bsky/src/data-plane/server/routes/blocks.ts b/packages/bsky/src/data-plane/server/routes/blocks.ts new file mode 100644 index 00000000000..54ec900b280 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/blocks.ts @@ -0,0 +1,121 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getBidirectionalBlock(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('actor_block') + .where((qb) => + qb + .where('actor_block.creator', '=', actorDid) + .where('actor_block.subjectDid', '=', targetDid), + ) + .orWhere((qb) => + qb + .where('actor_block.creator', '=', targetDid) + .where('actor_block.subjectDid', '=', actorDid), + ) + .limit(1) + .selectAll() + .executeTakeFirst() + + return { + blockUri: res?.uri, + } + }, + + async getBlocks(req) { + const { actorDid, cursor, limit } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('actor_block') + .where('actor_block.creator', '=', actorDid) + .selectAll() + + const keyset = new TimeCidKeyset( + ref('actor_block.sortAt'), + ref('actor_block.cid'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const blocks = await builder.execute() + return { + blockUris: blocks.map((b) => b.uri), + cursor: keyset.packFromResult(blocks), + } + }, + + async getBidirectionalBlockViaList(req) { + const { actorDid, targetDid } = req + const res = await db.db + .selectFrom('list_block') + .innerJoin('list_item', 'list_item.listUri', 'list_block.subjectUri') + .where((qb) => + qb + .where('list_block.creator', '=', actorDid) + .where('list_item.subjectDid', '=', targetDid), + ) + .orWhere((qb) => + qb + .where('list_block.creator', '=', targetDid) + .where('list_item.subjectDid', '=', actorDid), + ) + .limit(1) + .selectAll('list_block') + .executeTakeFirst() + + return { + listUri: res?.subjectUri, + } + }, + + async getBlocklistSubscription(req) { + const { actorDid, listUri } = req + const res = await db.db + .selectFrom('list_block') + .where('creator', '=', actorDid) + .where('subjectUri', '=', listUri) + .selectAll() + .limit(1) + .executeTakeFirst() + return { + listblockUri: res?.uri, + } + }, + + async getBlocklistSubscriptions(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('list') + .whereExists( + db.db + .selectFrom('list_block') + .where('list_block.creator', '=', actorDid) + .whereRef('list_block.subjectUri', '=', ref('list.uri')) + .selectAll(), + ) + .selectAll('list') + + const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + const lists = await builder.execute() + + return { + listUris: lists.map((l) => l.uri), + cursor: keyset.packFromResult(lists), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/feed-gens.ts b/packages/bsky/src/data-plane/server/routes/feed-gens.ts new file mode 100644 index 00000000000..129229f923f --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/feed-gens.ts @@ -0,0 +1,70 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorFeeds(req) { + const { actorDid, limit, cursor } = req + + const { ref } = db.db.dynamic + let builder = db.db + .selectFrom('feed_generator') + .selectAll() + .where('feed_generator.creator', '=', actorDid) + + const keyset = new TimeCidKeyset( + ref('feed_generator.createdAt'), + ref('feed_generator.cid'), + ) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + const feeds = await builder.execute() + + return { + uris: feeds.map((f) => f.uri), + cursor: keyset.packFromResult(feeds), + } + }, + + async getSuggestedFeeds(req) { + const feeds = await db.db + .selectFrom('suggested_feed') + .orderBy('suggested_feed.order', 'asc') + .if(!!req.cursor, (q) => q.where('order', '>', parseInt(req.cursor, 10))) + .limit(req.limit || 50) + .selectAll() + .execute() + return { + uris: feeds.map((f) => f.uri), + cursor: feeds.at(-1)?.order.toString(), + } + }, + + async searchFeedGenerators(req) { + const { ref } = db.db.dynamic + const limit = req.limit + const query = req.query.trim() + let builder = db.db + .selectFrom('feed_generator') + .if(!!query, (q) => q.where('displayName', 'ilike', `%${query}%`)) + .selectAll() + const keyset = new TimeCidKeyset( + ref('feed_generator.createdAt'), + ref('feed_generator.cid'), + ) + builder = paginate(builder, { limit, keyset }) + const feeds = await builder.execute() + return { + uris: feeds.map((f) => f.uri), + cursor: keyset.packFromResult(feeds), + } + }, + + async getFeedGeneratorStatus() { + throw new Error('unimplemented') + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/feeds.ts b/packages/bsky/src/data-plane/server/routes/feeds.ts new file mode 100644 index 00000000000..3cb56d57e3f --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/feeds.ts @@ -0,0 +1,148 @@ +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' +import { FeedType } from '../../../proto/bsky_pb' + +export default (db: Database): Partial> => ({ + async getAuthorFeed(req) { + const { actorDid, limit, cursor, feedType } = req + const { ref } = db.db.dynamic + + // defaults to posts, reposts, and replies + let builder = db.db + .selectFrom('feed_item') + .innerJoin('post', 'post.uri', 'feed_item.postUri') + .selectAll('feed_item') + .where('originatorDid', '=', actorDid) + + if (feedType === FeedType.POSTS_WITH_MEDIA) { + builder = builder + // only your own posts + .where('type', '=', 'post') + // only posts with media + .whereExists((qb) => + qb + .selectFrom('post_embed_image') + .select('post_embed_image.postUri') + .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), + ) + } else if (feedType === FeedType.POSTS_NO_REPLIES) { + builder = builder.where((qb) => + qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'), + ) + } else if (feedType === FeedType.POSTS_AND_AUTHOR_THREADS) { + builder = builder.where((qb) => + qb + .where('type', '=', 'repost') + .orWhere('post.replyParent', 'is', null) + .orWhere('post.replyRoot', 'like', `at://${actorDid}/%`), + ) + } + + const keyset = new TimeCidKeyset( + ref('feed_item.sortAt'), + ref('feed_item.cid'), + ) + + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const feedItems = await builder.execute() + + return { + items: feedItems.map(feedItemFromRow), + cursor: keyset.packFromResult(feedItems), + } + }, + + async getTimeline(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + const keyset = new TimeCidKeyset( + ref('feed_item.sortAt'), + ref('feed_item.cid'), + ) + + let followQb = db.db + .selectFrom('feed_item') + .innerJoin('follow', 'follow.subjectDid', 'feed_item.originatorDid') + .where('follow.creator', '=', actorDid) + .selectAll('feed_item') + + followQb = paginate(followQb, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + let selfQb = db.db + .selectFrom('feed_item') + .where('feed_item.originatorDid', '=', actorDid) + .selectAll('feed_item') + + selfQb = paginate(selfQb, { + limit: Math.min(limit, 10), + cursor, + keyset, + tryIndex: true, + }) + + const [followRes, selfRes] = await Promise.all([ + followQb.execute(), + selfQb.execute(), + ]) + + const feedItems = [...followRes, ...selfRes] + .sort((a, b) => { + if (a.sortAt > b.sortAt) return -1 + if (a.sortAt < b.sortAt) return 1 + return a.cid > b.cid ? -1 : 1 + }) + .slice(0, limit) + + return { + items: feedItems.map(feedItemFromRow), + cursor: keyset.packFromResult(feedItems), + } + }, + + async getListFeed(req) { + const { listUri, cursor, limit } = req + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('post') + .selectAll('post') + .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') + .where('list_item.listUri', '=', listUri) + + const keyset = new TimeCidKeyset(ref('post.sortAt'), ref('post.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + const feedItems = await builder.execute() + + return { + items: feedItems.map((item) => ({ uri: item.uri, cid: item.cid })), + cursor: keyset.packFromResult(feedItems), + } + }, +}) + +// @NOTE does not support additional fields in the protos specific to author feeds +// and timelines. at the time of writing, hydration/view implementations do not rely on them. +const feedItemFromRow = (row: { postUri: string; uri: string }) => { + return { + uri: row.postUri, + repost: row.uri === row.postUri ? undefined : row.uri, + } +} diff --git a/packages/bsky/src/data-plane/server/routes/follows.ts b/packages/bsky/src/data-plane/server/routes/follows.ts new file mode 100644 index 00000000000..1380fa281d6 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/follows.ts @@ -0,0 +1,95 @@ +import { keyBy } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { TimeCidKeyset, paginate } from '../db/pagination' + +export default (db: Database): Partial> => ({ + async getActorFollowsActors(req) { + const { actorDid, targetDids } = req + if (targetDids.length < 1) { + return { uris: [] } + } + const res = await db.db + .selectFrom('follow') + .where('follow.creator', '=', actorDid) + .where('follow.subjectDid', 'in', targetDids) + .selectAll() + .execute() + const bySubject = keyBy(res, 'subjectDid') + const uris = targetDids.map((did) => bySubject[did]?.uri ?? '') + return { + uris, + } + }, + async getFollowers(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + let followersReq = db.db + .selectFrom('follow') + .where('follow.subjectDid', '=', actorDid) + .innerJoin('actor as creator', 'creator.did', 'follow.creator') + .selectAll('creator') + .select([ + 'follow.uri as uri', + 'follow.cid as cid', + 'follow.creator as creatorDid', + 'follow.subjectDid as subjectDid', + 'follow.sortAt as sortAt', + ]) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followersReq = paginate(followersReq, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const followers = await followersReq.execute() + return { + followers: followers.map((f) => ({ + uri: f.uri, + actorDid: f.creatorDid, + subjectDid: f.subjectDid, + })), + cursor: keyset.packFromResult(followers), + } + }, + async getFollows(req) { + const { actorDid, limit, cursor } = req + const { ref } = db.db.dynamic + + let followsReq = db.db + .selectFrom('follow') + .where('follow.creator', '=', actorDid) + .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') + .selectAll('subject') + .select([ + 'follow.uri as uri', + 'follow.cid as cid', + 'follow.creator as creatorDid', + 'follow.subjectDid as subjectDid', + 'follow.sortAt as sortAt', + ]) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followsReq = paginate(followsReq, { + limit, + cursor, + keyset, + tryIndex: true, + }) + + const follows = await followsReq.execute() + + return { + follows: follows.map((f) => ({ + uri: f.uri, + actorDid: f.creatorDid, + subjectDid: f.subjectDid, + })), + cursor: keyset.packFromResult(follows), + } + }, +}) diff --git a/packages/bsky/src/data-plane/server/routes/identity.ts b/packages/bsky/src/data-plane/server/routes/identity.ts new file mode 100644 index 00000000000..fb8dffc096c --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/identity.ts @@ -0,0 +1,59 @@ +import { Code, ConnectError, ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { DidDocument, IdResolver, getDid, getHandle } from '@atproto/identity' +import { Timestamp } from '@bufbuild/protobuf' + +export default ( + _db: Database, + idResolver: IdResolver, +): Partial> => ({ + async getIdentityByDid(req) { + const doc = await idResolver.did.resolve(req.did) + if (!doc) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, + + async getIdentityByHandle(req) { + const did = await idResolver.handle.resolve(req.handle) + if (!did) { + throw new ConnectError('identity not found', Code.NotFound) + } + const doc = await idResolver.did.resolve(did) + if (!doc || did !== getDid(doc)) { + throw new ConnectError('identity not found', Code.NotFound) + } + return getResultFromDoc(doc) + }, +}) + +const getResultFromDoc = (doc: DidDocument) => { + const keys: Record = {} + doc.verificationMethod?.forEach((method) => { + const id = method.id.split('#').at(1) + if (!id) return + keys[id] = { + Type: method.type, + PublicKeyMultibase: method.publicKeyMultibase || '', + } + }) + const services: Record = {} + doc.service?.forEach((service) => { + const id = service.id.split('#').at(1) + if (!id) return + if (typeof service.serviceEndpoint !== 'string') return + services[id] = { + Type: service.type, + URL: service.serviceEndpoint, + } + }) + return { + did: getDid(doc), + handle: getHandle(doc), + keys: Buffer.from(JSON.stringify(keys)), + services: Buffer.from(JSON.stringify(services)), + updated: Timestamp.fromDate(new Date()), + } +} diff --git a/packages/bsky/src/data-plane/server/routes/index.ts b/packages/bsky/src/data-plane/server/routes/index.ts new file mode 100644 index 00000000000..6169a48a28d --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/index.ts @@ -0,0 +1,55 @@ +import { ConnectRouter } from '@connectrpc/connect' +import { IdResolver } from '@atproto/identity' +import { Service } from '../../../proto/bsky_connect' +import blocks from './blocks' +import feedGens from './feed-gens' +import feeds from './feeds' +import follows from './follows' +import identity from './identity' +import interactions from './interactions' +import labels from './labels' +import likes from './likes' +import lists from './lists' +import moderation from './moderation' +import mutes from './mutes' +import notifs from './notifs' +import posts from './posts' +import profile from './profile' +import records from './records' +import relationships from './relationships' +import reposts from './reposts' +import search from './search' +import suggestions from './suggestions' +import sync from './sync' +import threads from './threads' +import { Database } from '../db' + +export default (db: Database, idResolver: IdResolver) => + (router: ConnectRouter) => + router.service(Service, { + ...blocks(db), + ...feedGens(db), + ...feeds(db), + ...follows(db), + ...identity(db, idResolver), + ...interactions(db), + ...labels(db), + ...likes(db), + ...lists(db), + ...moderation(db), + ...mutes(db), + ...notifs(db), + ...posts(db), + ...profile(db), + ...records(db), + ...relationships(db), + ...reposts(db), + ...search(db), + ...suggestions(db), + ...sync(db), + ...threads(db), + + async ping() { + return {} + }, + }) diff --git a/packages/bsky/src/data-plane/server/routes/interactions.ts b/packages/bsky/src/data-plane/server/routes/interactions.ts new file mode 100644 index 00000000000..181bce53c23 --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/interactions.ts @@ -0,0 +1,56 @@ +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) { + const uris = req.refs.map((ref) => ref.uri) + if (uris.length === 0) { + return { likes: [], replies: [], reposts: [] } + } + const res = await db.db + .selectFrom('post_agg') + .where('uri', 'in', uris) + .selectAll() + .execute() + const byUri = keyBy(res, 'uri') + return { + likes: uris.map((uri) => byUri[uri]?.likeCount ?? 0), + replies: uris.map((uri) => byUri[uri]?.replyCount ?? 0), + reposts: uris.map((uri) => byUri[uri]?.repostCount ?? 0), + } + }, + async getCountsForUsers(req) { + if (req.dids.length === 0) { + return { followers: [], following: [], posts: [] } + } + const { ref } = db.db.dynamic + const res = await db.db + .selectFrom('profile_agg') + .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/labels.ts b/packages/bsky/src/data-plane/server/routes/labels.ts new file mode 100644 index 00000000000..a4dd2e6acac --- /dev/null +++ b/packages/bsky/src/data-plane/server/routes/labels.ts @@ -0,0 +1,46 @@ +import * as ui8 from 'uint8arrays' +import { noUndefinedVals } from '@atproto/common' +import { ServiceImpl } from '@connectrpc/connect' +import { Service } from '../../../proto/bsky_connect' +import { Database } from '../db' +import { Selectable } from 'kysely' +import { Label } from '../db/tables/label' + +type LabelRow = Selectable