diff --git a/.changeset/chilly-terms-add.md b/.changeset/chilly-terms-add.md deleted file mode 100644 index 0a92527fc84..00000000000 --- a/.changeset/chilly-terms-add.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -Added feed generator interaction lexicons diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index 45f7386945f..022d94f016a 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -122,6 +122,7 @@ "#adultContentPref", "#contentLabelPref", "#savedFeedsPref", + "#savedFeedsPrefV2", "#personalDetailsPref", "#feedViewPref", "#threadViewPref", @@ -154,6 +155,38 @@ } } }, + "savedFeed": { + "type": "object", + "required": ["id", "type", "value", "pinned"], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "knownValues": ["feed", "list", "timeline"] + }, + "value": { + "type": "string" + }, + "pinned": { + "type": "boolean" + } + } + }, + "savedFeedsPrefV2": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#savedFeed" + } + } + } + }, "savedFeedsPref": { "type": "object", "required": ["pinned", "saved"], diff --git a/lexicons/app/bsky/actor/searchActorsTypeahead.json b/lexicons/app/bsky/actor/searchActorsTypeahead.json index 3df9b92fb03..4e3cb1b4e88 100644 --- a/lexicons/app/bsky/actor/searchActorsTypeahead.json +++ b/lexicons/app/bsky/actor/searchActorsTypeahead.json @@ -16,11 +16,6 @@ "type": "string", "description": "Search query prefix; not a full query string." }, - "viewer": { - "type": "string", - "format": "did", - "description": "DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking." - }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json new file mode 100644 index 00000000000..1bd1fc8034d --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestionsSkeleton", + "defs": { + "main": { + "type": "query", + "description": "Get a skeleton of suggested actors. Intended to be called and then hydrated through app.bsky.actor.getSuggestions", + "parameters": { + "type": "params", + "properties": { + "viewer": { + "type": "string", + "format": "did", + "description": "DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "cursor": { "type": "string" }, + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.unspecced.defs#skeletonSearchActor" + } + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/createSession.json b/lexicons/com/atproto/server/createSession.json index cef01b45b35..930d051995f 100644 --- a/lexicons/com/atproto/server/createSession.json +++ b/lexicons/com/atproto/server/createSession.json @@ -15,7 +15,8 @@ "type": "string", "description": "Handle or other identifier supported by the server for the authenticating user." }, - "password": { "type": "string" } + "password": { "type": "string" }, + "authFactorToken": { "type": "string" } } } }, @@ -31,11 +32,15 @@ "did": { "type": "string", "format": "did" }, "didDoc": { "type": "unknown" }, "email": { "type": "string" }, - "emailConfirmed": { "type": "boolean" } + "emailConfirmed": { "type": "boolean" }, + "emailAuthFactor": { "type": "boolean" } } } }, - "errors": [{ "name": "AccountTakedown" }] + "errors": [ + { "name": "AccountTakedown" }, + { "name": "AuthFactorTokenRequired" } + ] } } } diff --git a/lexicons/com/atproto/server/getSession.json b/lexicons/com/atproto/server/getSession.json index 6b5f280e746..59f572d2931 100644 --- a/lexicons/com/atproto/server/getSession.json +++ b/lexicons/com/atproto/server/getSession.json @@ -15,6 +15,7 @@ "did": { "type": "string", "format": "did" }, "email": { "type": "string" }, "emailConfirmed": { "type": "boolean" }, + "emailAuthFactor": { "type": "boolean" }, "didDoc": { "type": "unknown" } } } diff --git a/lexicons/com/atproto/server/updateEmail.json b/lexicons/com/atproto/server/updateEmail.json index 88872698910..7d90247ab32 100644 --- a/lexicons/com/atproto/server/updateEmail.json +++ b/lexicons/com/atproto/server/updateEmail.json @@ -12,6 +12,7 @@ "required": ["email"], "properties": { "email": { "type": "string" }, + "emailAuthFactor": { "type": "boolean" }, "token": { "type": "string", "description": "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed." diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json index e88ac98303e..51dd2460609 100644 --- a/lexicons/tools/ozone/moderation/defs.json +++ b/lexicons/tools/ozone/moderation/defs.json @@ -25,6 +25,9 @@ "#modEventAcknowledge", "#modEventEscalate", "#modEventMute", + "#modEventUnmute", + "#modEventMuteReporter", + "#modEventUnmuteReporter", "#modEventEmail", "#modEventResolveAppeal", "#modEventDivert" @@ -67,6 +70,9 @@ "#modEventAcknowledge", "#modEventEscalate", "#modEventMute", + "#modEventUnmute", + "#modEventMuteReporter", + "#modEventUnmuteReporter", "#modEventEmail", "#modEventResolveAppeal", "#modEventDivert" @@ -128,6 +134,10 @@ "type": "string", "format": "datetime" }, + "muteReportingUntil": { + "type": "string", + "format": "datetime" + }, "lastReviewedBy": { "type": "string", "format": "did" @@ -242,6 +252,10 @@ "comment": { "type": "string" }, + "isReporterMuted": { + "type": "boolean", + "description": "Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject." + }, "reportType": { "type": "ref", "ref": "com.atproto.moderation.defs#reasonType" @@ -300,6 +314,28 @@ } } }, + "modEventMuteReporter": { + "type": "object", + "description": "Mute incoming reports from an account", + "required": ["durationInHours"], + "properties": { + "comment": { "type": "string" }, + "durationInHours": { + "type": "integer", + "description": "Indicates how long the account should remain muted." + } + } + }, + "modEventUnmuteReporter": { + "type": "object", + "description": "Unmute incoming reports from an account", + "properties": { + "comment": { + "type": "string", + "description": "Describe reasoning behind the reversal." + } + } + }, "modEventEmail": { "type": "object", "description": "Keep a log of outgoing email to a user", diff --git a/lexicons/tools/ozone/moderation/emitEvent.json b/lexicons/tools/ozone/moderation/emitEvent.json index 32c12065008..cae73d8fbcd 100644 --- a/lexicons/tools/ozone/moderation/emitEvent.json +++ b/lexicons/tools/ozone/moderation/emitEvent.json @@ -21,6 +21,9 @@ "tools.ozone.moderation.defs#modEventLabel", "tools.ozone.moderation.defs#modEventReport", "tools.ozone.moderation.defs#modEventMute", + "tools.ozone.moderation.defs#modEventUnmute", + "tools.ozone.moderation.defs#modEventMuteReporter", + "tools.ozone.moderation.defs#modEventUnmuteReporter", "tools.ozone.moderation.defs#modEventReverseTakedown", "tools.ozone.moderation.defs#modEventUnmute", "tools.ozone.moderation.defs#modEventEmail", diff --git a/lexicons/tools/ozone/moderation/queryStatuses.json b/lexicons/tools/ozone/moderation/queryStatuses.json index 624ffbbb4ad..81fc1e38f33 100644 --- a/lexicons/tools/ozone/moderation/queryStatuses.json +++ b/lexicons/tools/ozone/moderation/queryStatuses.json @@ -37,6 +37,10 @@ "type": "boolean", "description": "By default, we don't include muted subjects in the results. Set this to true to include them." }, + "onlyMuted": { + "type": "boolean", + "description": "When set to true, only muted subjects and reporters will be returned." + }, "reviewState": { "type": "string", "description": "Specify when fetching subjects in a certain state" diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 445b11ea7dc..44fca6b4fb5 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,39 @@ # @atproto/api +## 0.12.7 + +### Patch Changes + +- [#2390](https://github.com/bluesky-social/atproto/pull/2390) [`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933) Thanks [@foysalit](https://github.com/foysalit)! - Allow muting reports from accounts via `#modEventMuteReporter` event + +## 0.12.6 + +### Patch Changes + +- [#2427](https://github.com/bluesky-social/atproto/pull/2427) [`b9b7c5821`](https://github.com/bluesky-social/atproto/commit/b9b7c582199d57d2fe0af8af5c8c411ed34f5b9d) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Introduces V2 of saved feeds preferences. V2 and v1 prefs are incompatible. v1 + methods and preference objects are retained for backwards compatability, but are + considered deprecated. Developers should immediately migrate to v2 interfaces. + +## 0.12.5 + +### Patch Changes + +- [#2419](https://github.com/bluesky-social/atproto/pull/2419) [`3424a1770`](https://github.com/bluesky-social/atproto/commit/3424a17703891f5678ec76ef97e696afb3288b22) Thanks [@pfrazee](https://github.com/pfrazee)! - Add authFactorToken to session objects + +## 0.12.4 + +### Patch Changes + +- [#2416](https://github.com/bluesky-social/atproto/pull/2416) [`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05) Thanks [@devinivy](https://github.com/devinivy)! - Support for email auth factor lexicons + +## 0.12.3 + +### Patch Changes + +- [#2383](https://github.com/bluesky-social/atproto/pull/2383) [`0edef0ec0`](https://github.com/bluesky-social/atproto/commit/0edef0ec01403fd6097a4d2875b68313f2f1261f) Thanks [@dholms](https://github.com/dholms)! - Added feed generator interaction lexicons + +- [#2409](https://github.com/bluesky-social/atproto/pull/2409) [`c6d758b8b`](https://github.com/bluesky-social/atproto/commit/c6d758b8b63f4ef50b2ab9afc62164e92a53e7f0) Thanks [@devinivy](https://github.com/devinivy)! - Support for upcoming post search params + ## 0.12.2 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index e3aab40c8ae..c301818320e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.12.2", + "version": "0.12.7", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index bec2834e9e3..17ef3b978bd 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -148,6 +148,7 @@ export class AtpAgent { did: res.data.did, email: opts.email, emailConfirmed: false, + emailAuthFactor: false, } this._updateApiEndpoint(res.data.didDoc) return res @@ -173,6 +174,7 @@ export class AtpAgent { const res = await this.api.com.atproto.server.createSession({ identifier: opts.identifier, password: opts.password, + authFactorToken: opts.authFactorToken, }) this.session = { accessJwt: res.data.accessJwt, @@ -181,6 +183,7 @@ export class AtpAgent { did: res.data.did, email: res.data.email, emailConfirmed: res.data.emailConfirmed, + emailAuthFactor: res.data.emailAuthFactor, } this._updateApiEndpoint(res.data.didDoc) return res @@ -215,6 +218,7 @@ export class AtpAgent { this.session.email = res.data.email this.session.handle = res.data.handle this.session.emailConfirmed = res.data.emailConfirmed + this.session.emailAuthFactor = res.data.emailAuthFactor this._updateApiEndpoint(res.data.didDoc) this._persistSession?.('update', this.session) return res diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index e7e2a1717cc..c70a066978c 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -1,4 +1,5 @@ import { AtUri, ensureValidDid } from '@atproto/syntax' +import { TID } from '@atproto/common-web' import { AtpAgent } from './agent' import { AppBskyFeedPost, @@ -19,7 +20,12 @@ import { ModerationPrefs, } from './moderation/types' import { DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' -import { sanitizeMutedWordValue } from './util' +import { + sanitizeMutedWordValue, + validateSavedFeed, + savedFeedsToUriArrays, + getSavedFeedType, +} from './util' import { interpretLabelValueDefinitions } from './moderation' const FEED_VIEW_PREF_DEFAULTS = { @@ -365,6 +371,8 @@ export class BskyAgent extends AtpAgent { saved: undefined, pinned: undefined, }, + // @ts-ignore populating below + savedFeeds: undefined, feedViewPrefs: { home: { ...FEED_VIEW_PREF_DEFAULTS, @@ -412,6 +420,11 @@ export class BskyAgent extends AtpAgent { labels: {}, })), ) + } else if ( + AppBskyActorDefs.isSavedFeedsPrefV2(pref) && + AppBskyActorDefs.validateSavedFeedsPrefV2(pref).success + ) { + prefs.savedFeeds = pref.items } else if ( AppBskyActorDefs.isSavedFeedsPref(pref) && AppBskyActorDefs.validateSavedFeedsPref(pref).success @@ -467,6 +480,75 @@ export class BskyAgent extends AtpAgent { } } + /* + * If `prefs.savedFeeds` is undefined, no `savedFeedsPrefV2` exists, which + * means we want to try to migrate if needed. + * + * If v1 prefs exist, they will be migrated to v2. + * + * If no v1 prefs exist, the user is either new, or could be old and has + * never edited their feeds. + */ + if (prefs.savedFeeds === undefined) { + const { saved, pinned } = prefs.feeds + + if (saved && pinned) { + const uniqueMigratedSavedFeeds: Map< + string, + AppBskyActorDefs.SavedFeed + > = new Map() + + // insert Following feed first + uniqueMigratedSavedFeeds.set('timeline', { + id: TID.nextStr(), + type: 'timeline', + value: 'following', + pinned: true, + }) + + // use pinned as source of truth for feed order + for (const uri of pinned) { + const type = getSavedFeedType(uri) + // only want supported types + if (type === 'unknown') continue + uniqueMigratedSavedFeeds.set(uri, { + id: TID.nextStr(), + type, + value: uri, + pinned: true, + }) + } + + for (const uri of saved) { + if (!uniqueMigratedSavedFeeds.has(uri)) { + const type = getSavedFeedType(uri) + // only want supported types + if (type === 'unknown') continue + uniqueMigratedSavedFeeds.set(uri, { + id: TID.nextStr(), + type, + value: uri, + pinned: false, + }) + } + } + + prefs.savedFeeds = Array.from(uniqueMigratedSavedFeeds.values()) + } else { + prefs.savedFeeds = [ + { + id: TID.nextStr(), + type: 'timeline', + value: 'following', + pinned: true, + }, + ] + } + + // save to user preferences so this migration doesn't re-occur + await this.overwriteSavedFeeds(prefs.savedFeeds) + } + // apply the label prefs for (const pref of labelPrefs) { if (pref.labelerDid) { @@ -491,6 +573,63 @@ export class BskyAgent extends AtpAgent { return prefs } + async overwriteSavedFeeds(savedFeeds: AppBskyActorDefs.SavedFeed[]) { + savedFeeds.forEach(validateSavedFeed) + const uniqueSavedFeeds = new Map() + savedFeeds.forEach((feed) => { + // remove and re-insert to preserve order + if (uniqueSavedFeeds.has(feed.id)) { + uniqueSavedFeeds.delete(feed.id) + } + uniqueSavedFeeds.set(feed.id, feed) + }) + return updateSavedFeedsV2Preferences(this, () => + Array.from(uniqueSavedFeeds.values()), + ) + } + + async updateSavedFeeds(savedFeedsToUpdate: AppBskyActorDefs.SavedFeed[]) { + savedFeedsToUpdate.map(validateSavedFeed) + return updateSavedFeedsV2Preferences(this, (savedFeeds) => { + return savedFeeds.map((savedFeed) => { + const updatedVersion = savedFeedsToUpdate.find( + (updated) => savedFeed.id === updated.id, + ) + if (updatedVersion) { + return { + ...savedFeed, + // only update pinned + pinned: updatedVersion.pinned, + } + } + return savedFeed + }) + }) + } + + async addSavedFeeds( + savedFeeds: Pick[], + ) { + const toSave: AppBskyActorDefs.SavedFeed[] = savedFeeds.map((f) => ({ + ...f, + id: TID.nextStr(), + })) + toSave.forEach(validateSavedFeed) + return updateSavedFeedsV2Preferences(this, (savedFeeds) => [ + ...savedFeeds, + ...toSave, + ]) + } + + async removeSavedFeeds(ids: string[]) { + return updateSavedFeedsV2Preferences(this, (savedFeeds) => [ + ...savedFeeds.filter((feed) => !ids.find((id) => feed.id === id)), + ]) + } + + /** + * @deprecated use `overwriteSavedFeeds` + */ async setSavedFeeds(saved: string[], pinned: string[]) { return updateFeedPreferences(this, () => ({ saved, @@ -498,6 +637,9 @@ export class BskyAgent extends AtpAgent { })) } + /** + * @deprecated use `addSavedFeeds` + */ async addSavedFeed(v: string) { return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({ saved: [...saved.filter((uri) => uri !== v), v], @@ -505,6 +647,9 @@ export class BskyAgent extends AtpAgent { })) } + /** + * @deprecated use `removeSavedFeeds` + */ async removeSavedFeed(v: string) { return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({ saved: saved.filter((uri) => uri !== v), @@ -512,6 +657,9 @@ export class BskyAgent extends AtpAgent { })) } + /** + * @deprecated use `addSavedFeeds` or `updateSavedFeeds` + */ async addPinnedFeed(v: string) { return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({ saved: [...saved.filter((uri) => uri !== v), v], @@ -519,6 +667,9 @@ export class BskyAgent extends AtpAgent { })) } + /** + * @deprecated use `updateSavedFeeds` or `removeSavedFeeds` + */ async removePinnedFeed(v: string) { return updateFeedPreferences(this, (saved: string[], pinned: string[]) => ({ saved, @@ -945,6 +1096,76 @@ async function updateFeedPreferences( return res } +async function updateSavedFeedsV2Preferences( + agent: BskyAgent, + cb: ( + savedFeedsPref: AppBskyActorDefs.SavedFeed[], + ) => AppBskyActorDefs.SavedFeed[], +): Promise { + let maybeMutatedSavedFeeds: AppBskyActorDefs.SavedFeed[] = [] + + await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => { + let existingV2Pref = prefs.findLast( + (pref) => + AppBskyActorDefs.isSavedFeedsPrefV2(pref) && + AppBskyActorDefs.validateSavedFeedsPrefV2(pref).success, + ) as AppBskyActorDefs.SavedFeedsPrefV2 | undefined + let existingV1Pref = prefs.findLast( + (pref) => + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success, + ) as AppBskyActorDefs.SavedFeedsPref | undefined + + if (existingV2Pref) { + maybeMutatedSavedFeeds = cb(existingV2Pref.items) + existingV2Pref = { + ...existingV2Pref, + items: maybeMutatedSavedFeeds, + } + } else { + maybeMutatedSavedFeeds = cb([]) + existingV2Pref = { + $type: 'app.bsky.actor.defs#savedFeedsPrefV2', + items: maybeMutatedSavedFeeds, + } + } + + // enforce ordering, pinned then saved + const pinned = existingV2Pref.items.filter((i) => i.pinned) + const saved = existingV2Pref.items.filter((i) => !i.pinned) + existingV2Pref.items = pinned.concat(saved) + + let updatedPrefs = prefs + .filter((pref) => !AppBskyActorDefs.isSavedFeedsPrefV2(pref)) + .concat(existingV2Pref) + + /* + * If there's a v2 pref present, it means this account was migrated from v1 + * to v2. During the transition period, we double write v2 prefs back to + * v1, but NOT the other way around. + */ + if (existingV1Pref) { + const { saved, pinned } = existingV1Pref + const v2Compat = savedFeedsToUriArrays( + // v1 only supports feeds and lists + existingV2Pref.items.filter((i) => ['feed', 'list'].includes(i.type)), + ) + existingV1Pref = { + ...existingV1Pref, + saved: Array.from(new Set([...saved, ...v2Compat.saved])), + pinned: Array.from(new Set([...pinned, ...v2Compat.pinned])), + } + updatedPrefs = updatedPrefs + .filter((pref) => !AppBskyActorDefs.isSavedFeedsPref(pref)) + .concat(existingV1Pref) + } + + return updatedPrefs + }) + + return maybeMutatedSavedFeeds +} + /** * Helper to transform the legacy content preferences. */ diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 689607eb3d8..6ef6428b3ea 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -150,6 +150,7 @@ import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/up import * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet' import * as AppBskyUnspeccedDefs from './types/app/bsky/unspecced/defs' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton' 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' @@ -310,6 +311,7 @@ export * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/up export * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet' export * as AppBskyUnspeccedDefs from './types/app/bsky/unspecced/defs' export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +export * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton' 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' @@ -2635,6 +2637,22 @@ export class AppBskyUnspeccedNS { }) } + getSuggestionsSkeleton( + params?: AppBskyUnspeccedGetSuggestionsSkeleton.QueryParams, + opts?: AppBskyUnspeccedGetSuggestionsSkeleton.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'app.bsky.unspecced.getSuggestionsSkeleton', + params, + undefined, + opts, + ) + .catch((e) => { + throw AppBskyUnspeccedGetSuggestionsSkeleton.toKnownErr(e) + }) + } + getTaggedSuggestions( params?: AppBskyUnspeccedGetTaggedSuggestions.QueryParams, opts?: AppBskyUnspeccedGetTaggedSuggestions.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index a41d998eeeb..f2886955a36 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2209,6 +2209,9 @@ export const schemaDict = { password: { type: 'string', }, + authFactorToken: { + type: 'string', + }, }, }, }, @@ -2241,6 +2244,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, }, }, }, @@ -2248,6 +2254,9 @@ export const schemaDict = { { name: 'AccountTakedown', }, + { + name: 'AuthFactorTokenRequired', + }, ], }, }, @@ -2568,6 +2577,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, didDoc: { type: 'unknown', }, @@ -2837,6 +2849,9 @@ export const schemaDict = { email: { type: 'string', }, + emailAuthFactor: { + type: 'boolean', + }, token: { type: 'string', description: @@ -3807,6 +3822,7 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#adultContentPref', 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', + 'lex:app.bsky.actor.defs#savedFeedsPrefV2', 'lex:app.bsky.actor.defs#personalDetailsPref', 'lex:app.bsky.actor.defs#feedViewPref', 'lex:app.bsky.actor.defs#threadViewPref', @@ -3845,6 +3861,38 @@ export const schemaDict = { }, }, }, + savedFeed: { + type: 'object', + required: ['id', 'type', 'value', 'pinned'], + properties: { + id: { + type: 'string', + }, + type: { + type: 'string', + knownValues: ['feed', 'list', 'timeline'], + }, + value: { + type: 'string', + }, + pinned: { + type: 'boolean', + }, + }, + }, + savedFeedsPrefV2: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#savedFeed', + }, + }, + }, + }, savedFeedsPref: { type: 'object', required: ['pinned', 'saved'], @@ -4307,12 +4355,6 @@ export const schemaDict = { type: 'string', description: 'Search query prefix; not a full query string.', }, - viewer: { - type: 'string', - format: 'did', - description: - 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', - }, limit: { type: 'integer', minimum: 1, @@ -7849,6 +7891,56 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetSuggestionsSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestionsSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested actors. Intended to be called and then hydrated through app.bsky.actor.getSuggestions', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + cursor: { + type: 'string', + }, + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyUnspeccedGetTaggedSuggestions: { lexicon: 1, id: 'app.bsky.unspecced.getTaggedSuggestions', @@ -8316,6 +8408,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8375,6 +8470,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8454,6 +8552,10 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + muteReportingUntil: { + type: 'string', + format: 'datetime', + }, lastReviewedBy: { type: 'string', format: 'did', @@ -8577,6 +8679,11 @@ export const schemaDict = { comment: { type: 'string', }, + isReporterMuted: { + type: 'boolean', + description: + "Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.", + }, reportType: { type: 'ref', ref: 'lex:com.atproto.moderation.defs#reasonType', @@ -8645,6 +8752,30 @@ export const schemaDict = { }, }, }, + modEventMuteReporter: { + type: 'object', + description: 'Mute incoming reports from an account', + required: ['durationInHours'], + properties: { + comment: { + type: 'string', + }, + durationInHours: { + type: 'integer', + description: 'Indicates how long the account should remain muted.', + }, + }, + }, + modEventUnmuteReporter: { + type: 'object', + description: 'Unmute incoming reports from an account', + properties: { + comment: { + type: 'string', + description: 'Describe reasoning behind the reversal.', + }, + }, + }, modEventEmail: { type: 'object', description: 'Keep a log of outgoing email to a user', @@ -9029,6 +9160,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventLabel', 'lex:tools.ozone.moderation.defs#modEventReport', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', 'lex:tools.ozone.moderation.defs#modEventUnmute', 'lex:tools.ozone.moderation.defs#modEventEmail', @@ -9337,6 +9471,11 @@ export const schemaDict = { description: "By default, we don't include muted subjects in the results. Set this to true to include them.", }, + onlyMuted: { + type: 'boolean', + description: + 'When set to true, only muted subjects and reporters will be returned.', + }, reviewState: { type: 'string', description: 'Specify when fetching subjects in a certain state', @@ -9627,6 +9766,8 @@ export const ids = { AppBskyUnspeccedDefs: 'app.bsky.unspecced.defs', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetSuggestionsSkeleton: + 'app.bsky.unspecced.getSuggestionsSkeleton', AppBskyUnspeccedGetTaggedSuggestions: 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedSearchActorsSkeleton: 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 d82df90d96c..c34c9a16425 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -132,6 +132,7 @@ export type Preferences = ( | AdultContentPref | ContentLabelPref | SavedFeedsPref + | SavedFeedsPrefV2 | PersonalDetailsPref | FeedViewPref | ThreadViewPref @@ -178,6 +179,43 @@ export function validateContentLabelPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v) } +export interface SavedFeed { + id: string + type: 'feed' | 'list' | 'timeline' | (string & {}) + value: string + pinned: boolean + [k: string]: unknown +} + +export function isSavedFeed(v: unknown): v is SavedFeed { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeed' + ) +} + +export function validateSavedFeed(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeed', v) +} + +export interface SavedFeedsPrefV2 { + items: SavedFeed[] + [k: string]: unknown +} + +export function isSavedFeedsPrefV2(v: unknown): v is SavedFeedsPrefV2 { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeedsPrefV2' + ) +} + +export function validateSavedFeedsPrefV2(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeedsPrefV2', v) +} + export interface SavedFeedsPref { pinned: string[] saved: string[] diff --git a/packages/api/src/client/types/app/bsky/actor/searchActorsTypeahead.ts b/packages/api/src/client/types/app/bsky/actor/searchActorsTypeahead.ts index 49ec37cdeb6..a91e0ce7dcd 100644 --- a/packages/api/src/client/types/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/api/src/client/types/app/bsky/actor/searchActorsTypeahead.ts @@ -13,8 +13,6 @@ export interface QueryParams { term?: string /** Search query prefix; not a full query string. */ q?: string - /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ - viewer?: string limit?: number } diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts new file mode 100644 index 00000000000..7951513f5bd --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts @@ -0,0 +1,40 @@ +/** + * 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 AppBskyUnspeccedDefs from './defs' + +export interface QueryParams { + /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ + viewer?: string + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + actors: AppBskyUnspeccedDefs.SkeletonSearchActor[] + [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/createSession.ts b/packages/api/src/client/types/com/atproto/server/createSession.ts index a06a7a86c6c..b478b754fb3 100644 --- a/packages/api/src/client/types/com/atproto/server/createSession.ts +++ b/packages/api/src/client/types/com/atproto/server/createSession.ts @@ -13,6 +13,7 @@ export interface InputSchema { /** Handle or other identifier supported by the server for the authenticating user. */ identifier: string password: string + authFactorToken?: string [k: string]: unknown } @@ -24,6 +25,7 @@ export interface OutputSchema { didDoc?: {} email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean [k: string]: unknown } @@ -45,9 +47,17 @@ export class AccountTakedownError extends XRPCError { } } +export class AuthFactorTokenRequiredError 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 === 'AccountTakedown') return new AccountTakedownError(e) + if (e.error === 'AuthFactorTokenRequired') + return new AuthFactorTokenRequiredError(e) } return e } diff --git a/packages/api/src/client/types/com/atproto/server/getSession.ts b/packages/api/src/client/types/com/atproto/server/getSession.ts index 6b82781f081..3a35a51a0d2 100644 --- a/packages/api/src/client/types/com/atproto/server/getSession.ts +++ b/packages/api/src/client/types/com/atproto/server/getSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { did: string email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean didDoc?: {} [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/updateEmail.ts b/packages/api/src/client/types/com/atproto/server/updateEmail.ts index 92aef734e20..0f630ec8a97 100644 --- a/packages/api/src/client/types/com/atproto/server/updateEmail.ts +++ b/packages/api/src/client/types/com/atproto/server/updateEmail.ts @@ -11,6 +11,7 @@ export interface QueryParams {} export interface InputSchema { email: string + emailAuthFactor?: boolean /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ token?: string [k: string]: unknown diff --git a/packages/api/src/client/types/tools/ozone/moderation/defs.ts b/packages/api/src/client/types/tools/ozone/moderation/defs.ts index f6f546b6bef..0277ad1b6c8 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/defs.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/defs.ts @@ -22,6 +22,9 @@ export interface ModEventView { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -61,6 +64,9 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -105,6 +111,7 @@ export interface SubjectStatusView { /** Sticky comment on the subject. */ comment?: string muteUntil?: string + muteReportingUntil?: string lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string @@ -237,6 +244,8 @@ export function validateModEventComment(v: unknown): ValidationResult { /** Report a subject */ export interface ModEventReport { comment?: string + /** Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject. */ + isReporterMuted?: boolean reportType: ComAtprotoModerationDefs.ReasonType [k: string]: unknown } @@ -346,6 +355,53 @@ export function validateModEventUnmute(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#modEventUnmute', v) } +/** Mute incoming reports from an account */ +export interface ModEventMuteReporter { + comment?: string + /** Indicates how long the account should remain muted. */ + durationInHours: number + [k: string]: unknown +} + +export function isModEventMuteReporter(v: unknown): v is ModEventMuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventMuteReporter' + ) +} + +export function validateModEventMuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventMuteReporter', + v, + ) +} + +/** Unmute incoming reports from an account */ +export interface ModEventUnmuteReporter { + /** Describe reasoning behind the reversal. */ + comment?: string + [k: string]: unknown +} + +export function isModEventUnmuteReporter( + v: unknown, +): v is ModEventUnmuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventUnmuteReporter' + ) +} + +export function validateModEventUnmuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventUnmuteReporter', + v, + ) +} + /** Keep a log of outgoing email to a user */ export interface ModEventEmail { /** The subject line of the email sent to the user. */ diff --git a/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts index 49ad72d208c..153dbbe9885 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts @@ -21,6 +21,9 @@ export interface InputSchema { | ToolsOzoneModerationDefs.ModEventLabel | ToolsOzoneModerationDefs.ModEventReport | ToolsOzoneModerationDefs.ModEventMute + | ToolsOzoneModerationDefs.ModEventUnmute + | ToolsOzoneModerationDefs.ModEventMuteReporter + | ToolsOzoneModerationDefs.ModEventUnmuteReporter | ToolsOzoneModerationDefs.ModEventReverseTakedown | ToolsOzoneModerationDefs.ModEventUnmute | ToolsOzoneModerationDefs.ModEventEmail diff --git a/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts b/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts index 55701ca94d4..10453220a33 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/queryStatuses.ts @@ -22,6 +22,8 @@ export interface QueryParams { reviewedBefore?: string /** By default, we don't include muted subjects in the results. Set this to true to include them. */ includeMuted?: boolean + /** When set to true, only muted subjects and reporters will be returned. */ + onlyMuted?: boolean /** Specify when fetching subjects in a certain state */ reviewState?: string ignoreSubjects?: string[] diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index a633ff79a33..60a26d3545b 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -26,6 +26,7 @@ export interface AtpSessionData { did: string email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean } /** @@ -50,6 +51,7 @@ export interface AtpAgentOpts { export interface AtpAgentLoginOpts { identifier: string password: string + authFactorToken?: string | undefined } /** @@ -110,10 +112,14 @@ export interface BskyInterestsPreference { * Bluesky preferences */ export interface BskyPreferences { + /** + * @deprecated use `savedFeeds` + */ feeds: { saved?: string[] pinned?: string[] } + savedFeeds: AppBskyActorDefs.SavedFeed[] feedViewPrefs: Record threadViewPrefs: BskyThreadViewPreference moderationPrefs: ModerationPrefs diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 58d60a5e48b..cca171dbfb0 100644 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -1,6 +1,78 @@ +import { AtUri } from '@atproto/syntax' +import { TID } from '@atproto/common-web' + +import { AppBskyActorDefs } from './client' + export function sanitizeMutedWordValue(value: string) { return value .trim() .replace(/^#(?!\ufe0f)/, '') .replace(/[\r\n\u00AD\u2060\u200D\u200C\u200B]+/, '') } + +export function savedFeedsToUriArrays( + savedFeeds: AppBskyActorDefs.SavedFeed[], +): { + pinned: string[] + saved: string[] +} { + const pinned: string[] = [] + const saved: string[] = [] + + for (const feed of savedFeeds) { + if (feed.pinned) { + pinned.push(feed.value) + // saved in v1 includes pinned + saved.push(feed.value) + } else { + saved.push(feed.value) + } + } + + return { + pinned, + saved, + } +} + +/** + * Get the type of a saved feed, used by deprecated methods for backwards + * compat. Should not be used moving forward. *Invalid URIs will throw.* + * + * @param uri - The AT URI of the saved feed + */ +export function getSavedFeedType( + uri: string, +): AppBskyActorDefs.SavedFeed['type'] { + const urip = new AtUri(uri) + + switch (urip.collection) { + case 'app.bsky.feed.generator': + return 'feed' + case 'app.bsky.graph.list': + return 'list' + default: + return 'unknown' + } +} + +export function validateSavedFeed(savedFeed: AppBskyActorDefs.SavedFeed) { + new TID(savedFeed.id) + + if (['feed', 'list'].includes(savedFeed.type)) { + const uri = new AtUri(savedFeed.value) + const isFeed = uri.collection === 'app.bsky.feed.generator' + const isList = uri.collection === 'app.bsky.graph.list' + + if (savedFeed.type === 'feed' && !isFeed) { + throw new Error( + `Saved feed of type 'feed' must be a feed, got ${uri.collection}`, + ) + } + if (savedFeed.type === 'list' && !isList) { + throw new Error( + `Saved feed of type 'list' must be a list, got ${uri.collection}`, + ) + } + } +} diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index e0713597a3d..0b3fcdd4c4a 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -1,10 +1,17 @@ import { TestNetworkNoAppView } from '@atproto/dev-env' +import { TID } from '@atproto/common-web' import { BskyAgent, ComAtprotoRepoPutRecord, AppBskyActorProfile, DEFAULT_LABEL_SETTINGS, -} from '..' +} from '../src' +import { + savedFeedsToUriArrays, + getSavedFeedType, + validateSavedFeed, +} from '../src/util' +import { AppBskyActorDefs } from '../dist' describe('agent', () => { let network: TestNetworkNoAppView @@ -237,6 +244,14 @@ describe('agent', () => { await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, @@ -266,6 +281,14 @@ describe('agent', () => { await agent.setAdultContentEnabled(true) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: true, labels: DEFAULT_LABEL_SETTINGS, @@ -295,6 +318,14 @@ describe('agent', () => { await agent.setAdultContentEnabled(false) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: DEFAULT_LABEL_SETTINGS, @@ -324,6 +355,14 @@ describe('agent', () => { await agent.setContentLabelPref('misinfo', 'hide') await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: { ...DEFAULT_LABEL_SETTINGS, misinfo: 'hide' }, @@ -353,6 +392,14 @@ describe('agent', () => { await agent.setContentLabelPref('spam', 'ignore') await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: { @@ -385,6 +432,14 @@ describe('agent', () => { await agent.addSavedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: [], saved: ['at://bob.com/app.bsky.feed.generator/fake'], @@ -421,6 +476,14 @@ describe('agent', () => { await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], @@ -457,6 +520,14 @@ describe('agent', () => { await agent.removePinnedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: [], saved: ['at://bob.com/app.bsky.feed.generator/fake'], @@ -493,6 +564,14 @@ describe('agent', () => { await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: [], saved: [], @@ -529,6 +608,14 @@ describe('agent', () => { await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], @@ -565,6 +652,14 @@ describe('agent', () => { await agent.addPinnedFeed('at://bob.com/app.bsky.feed.generator/fake2') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: [ 'at://bob.com/app.bsky.feed.generator/fake', @@ -607,6 +702,14 @@ describe('agent', () => { await agent.removeSavedFeed('at://bob.com/app.bsky.feed.generator/fake') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -643,6 +746,14 @@ describe('agent', () => { await agent.setPersonalDetails({ birthDate: '2023-09-11T18:05:42.556Z' }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -679,6 +790,14 @@ describe('agent', () => { await agent.setFeedViewPrefs('home', { hideReplies: true }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -715,6 +834,14 @@ describe('agent', () => { await agent.setFeedViewPrefs('home', { hideReplies: false }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -751,6 +878,14 @@ describe('agent', () => { await agent.setFeedViewPrefs('other', { hideReplies: true }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -794,6 +929,14 @@ describe('agent', () => { await agent.setThreadViewPrefs({ sort: 'random' }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -837,6 +980,14 @@ describe('agent', () => { await agent.setThreadViewPrefs({ sort: 'oldest' }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -880,6 +1031,14 @@ describe('agent', () => { await agent.setInterestsPref({ tags: ['foo', 'bar'] }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], feeds: { pinned: ['at://bob.com/app.bsky.feed.generator/fake2'], saved: ['at://bob.com/app.bsky.feed.generator/fake2'], @@ -1039,6 +1198,14 @@ describe('agent', () => { ], }) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ], feeds: { pinned: [], saved: [], @@ -1084,6 +1251,14 @@ describe('agent', () => { await agent.setAdultContentEnabled(false) await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ], feeds: { pinned: [], saved: [], @@ -1129,6 +1304,14 @@ describe('agent', () => { await agent.setContentLabelPref('porn', 'ignore') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ], feeds: { pinned: [], saved: [], @@ -1175,6 +1358,14 @@ describe('agent', () => { await agent.removeLabeler('did:plc:other') await expect(agent.getPreferences()).resolves.toStrictEqual({ + savedFeeds: [ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ], feeds: { pinned: [], saved: [], @@ -1221,6 +1412,14 @@ describe('agent', () => { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: { @@ -1263,6 +1462,14 @@ describe('agent', () => { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: { @@ -1316,6 +1523,14 @@ describe('agent', () => { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], }, + savedFeeds: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], moderationPrefs: { adultContentEnabled: false, labels: { @@ -1353,7 +1568,7 @@ describe('agent', () => { }) const res = await agent.app.bsky.actor.getPreferences() - await expect(res.data.preferences.sort(byType)).toStrictEqual( + expect(res.data.preferences.sort(byType)).toStrictEqual( [ { $type: 'app.bsky.actor.defs#adultContentPref', @@ -1382,6 +1597,17 @@ describe('agent', () => { pinned: ['at://bob.com/app.bsky.feed.generator/fake'], saved: ['at://bob.com/app.bsky.feed.generator/fake'], }, + { + $type: 'app.bsky.actor.defs#savedFeedsPrefV2', + items: [ + { + id: expect.any(String), + pinned: true, + type: 'timeline', + value: 'following', + }, + ], + }, { $type: 'app.bsky.actor.defs#personalDetailsPref', birthDate: '2023-09-11T18:05:42.556Z', @@ -1674,6 +1900,799 @@ describe('agent', () => { }) }) + describe(`saved feeds v2`, () => { + let agent: BskyAgent + let i = 0 + const feedUri = () => `at://bob.com/app.bsky.feed.generator/${i++}` + const listUri = () => `at://bob.com/app.bsky.graph.list/${i++}` + + beforeAll(async () => { + agent = new BskyAgent({ service: network.pds.url }) + await agent.createAccount({ + handle: 'user9.test', + email: 'user9@test.com', + password: 'password', + }) + }) + + beforeEach(async () => { + await agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + }) + + describe(`addSavedFeeds`, () => { + it('works', async () => { + const feed = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([feed]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + ...feed, + id: expect.any(String), + }, + ]) + }) + + it('throws if feed is specified and list provided', async () => { + const list = listUri() + await expect(() => + agent.addSavedFeeds([ + { + type: 'feed', + value: list, + pinned: true, + }, + ]), + ).rejects.toThrow() + }) + + it('throws if list is specified and feed provided', async () => { + const feed = feedUri() + await expect(() => + agent.addSavedFeeds([ + { + type: 'list', + value: feed, + pinned: true, + }, + ]), + ).rejects.toThrow() + }) + + it(`timeline`, async () => { + const feeds = await agent.addSavedFeeds([ + { + type: 'timeline', + value: 'following', + pinned: true, + }, + ]) + const prefs = await agent.getPreferences() + expect( + prefs.savedFeeds.filter((f) => f.type === 'timeline'), + ).toStrictEqual(feeds) + }) + + it(`allows duplicates`, async () => { + const feed = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([feed]) + await agent.addSavedFeeds([feed]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + ...feed, + id: expect.any(String), + }, + { + ...feed, + id: expect.any(String), + }, + ]) + }) + + it(`adds multiple`, async () => { + const a = { + type: 'feed', + value: feedUri(), + pinned: true, + } + const b = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([a, b]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + ...a, + id: expect.any(String), + }, + { + ...b, + id: expect.any(String), + }, + ]) + }) + + it(`appends multiple`, async () => { + const a = { + type: 'feed', + value: feedUri(), + pinned: true, + } + const b = { + type: 'feed', + value: feedUri(), + pinned: false, + } + const c = { + type: 'feed', + value: feedUri(), + pinned: true, + } + const d = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([a, b]) + await agent.addSavedFeeds([c, d]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + ...a, + id: expect.any(String), + }, + { + ...c, + id: expect.any(String), + }, + { + ...b, + id: expect.any(String), + }, + { + ...d, + id: expect.any(String), + }, + ]) + }) + }) + + describe(`removeSavedFeeds`, () => { + it('works', async () => { + const feed = { + type: 'feed', + value: feedUri(), + pinned: true, + } + const savedFeeds = await agent.addSavedFeeds([feed]) + await agent.removeSavedFeeds([savedFeeds[0].id]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([]) + }) + }) + + describe(`overwriteSavedFeeds`, () => { + it(`dedupes by id, takes last, preserves order based on last found`, async () => { + const a = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + const b = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + await agent.overwriteSavedFeeds([a, b, a]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([b, a]) + }) + + it(`preserves order`, async () => { + const a = feedUri() + const b = feedUri() + const c = feedUri() + const d = feedUri() + + await agent.overwriteSavedFeeds([ + { + id: TID.nextStr(), + type: 'timeline', + value: a, + pinned: true, + }, + { + id: TID.nextStr(), + type: 'feed', + value: b, + pinned: false, + }, + { + id: TID.nextStr(), + type: 'feed', + value: c, + pinned: true, + }, + { + id: TID.nextStr(), + type: 'feed', + value: d, + pinned: false, + }, + ]) + + const { savedFeeds } = await agent.getPreferences() + expect(savedFeeds.filter((f) => f.pinned)).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: a, + pinned: true, + }, + { + id: expect.any(String), + type: 'feed', + value: c, + pinned: true, + }, + ]) + expect(savedFeeds.filter((f) => !f.pinned)).toEqual([ + { + id: expect.any(String), + type: 'feed', + value: b, + pinned: false, + }, + { + id: expect.any(String), + type: 'feed', + value: d, + pinned: false, + }, + ]) + }) + }) + + describe(`updateSavedFeeds`, () => { + it(`updates affect order, saved last, new pins last`, async () => { + const a = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + const b = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + const c = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + + await agent.overwriteSavedFeeds([a, b, c]) + await agent.updateSavedFeeds([ + { + ...b, + pinned: false, + }, + ]) + + const prefs1 = await agent.getPreferences() + expect(prefs1.savedFeeds).toStrictEqual([ + a, + c, + { + ...b, + pinned: false, + }, + ]) + + await agent.updateSavedFeeds([ + { + ...b, + pinned: true, + }, + ]) + + const prefs2 = await agent.getPreferences() + expect(prefs2.savedFeeds).toStrictEqual([a, c, b]) + }) + + it(`cannot override original id`, async () => { + const a = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: true, + } + await agent.overwriteSavedFeeds([a]) + await agent.updateSavedFeeds([ + { + ...a, + pinned: false, + id: TID.nextStr(), + }, + ]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([a]) + }) + + it(`updates multiple`, async () => { + const a = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: false, + } + const b = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: false, + } + const c = { + id: TID.nextStr(), + type: 'feed', + value: feedUri(), + pinned: false, + } + + await agent.overwriteSavedFeeds([a, b, c]) + await agent.updateSavedFeeds([ + { + ...b, + pinned: true, + }, + { + ...c, + pinned: true, + }, + ]) + + const prefs1 = await agent.getPreferences() + expect(prefs1.savedFeeds).toStrictEqual([ + { + ...b, + pinned: true, + }, + { + ...c, + pinned: true, + }, + a, + ]) + }) + }) + + describe(`utils`, () => { + describe(`savedFeedsToUriArrays`, () => { + const { saved, pinned } = savedFeedsToUriArrays([ + { + id: '', + type: 'feed', + value: 'a', + pinned: true, + }, + { + id: '', + type: 'feed', + value: 'b', + pinned: false, + }, + { + id: '', + type: 'feed', + value: 'c', + pinned: true, + }, + ]) + expect(saved).toStrictEqual(['a', 'b', 'c']) + expect(pinned).toStrictEqual(['a', 'c']) + }) + + describe(`getSavedFeedType`, () => { + it(`works`, () => { + expect(getSavedFeedType('foo')).toBe('unknown') + expect(getSavedFeedType(feedUri())).toBe('feed') + expect(getSavedFeedType(listUri())).toBe('list') + expect( + getSavedFeedType('at://did:plc:fake/app.bsky.graph.follow/fake'), + ).toBe('unknown') + }) + }) + + describe(`validateSavedFeed`, () => { + it(`throws if invalid TID`, () => { + // really only checks length at time of writing + expect(() => + validateSavedFeed({ + id: 'a', + type: 'feed', + value: feedUri(), + pinned: false, + }), + ).toThrow() + }) + + it(`throws if mismatched types`, () => { + expect(() => + validateSavedFeed({ + id: TID.nextStr(), + type: 'list', + value: feedUri(), + pinned: false, + }), + ).toThrow() + expect(() => + validateSavedFeed({ + id: TID.nextStr(), + type: 'feed', + value: listUri(), + pinned: false, + }), + ).toThrow() + }) + + it(`ignores values it can't validate`, () => { + expect(() => + validateSavedFeed({ + id: TID.nextStr(), + type: 'timeline', + value: 'following', + pinned: false, + }), + ).not.toThrow() + expect(() => + validateSavedFeed({ + id: TID.nextStr(), + type: 'unknown', + value: 'could be @nyt4!ng', + pinned: false, + }), + ).not.toThrow() + }) + }) + }) + }) + + describe(`saved feeds v2: migration scenarios`, () => { + let agent: BskyAgent + let i = 0 + const feedUri = () => `at://bob.com/app.bsky.feed.generator/${i++}` + + beforeAll(async () => { + agent = new BskyAgent({ service: network.pds.url }) + await agent.createAccount({ + handle: 'user10.test', + email: 'user10@test.com', + password: 'password', + }) + }) + + beforeEach(async () => { + await agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + }) + + it('CRUD action before migration, no timeline inserted', async () => { + const feed = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([feed]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + ...feed, + id: expect.any(String), + }, + ]) + }) + + it('CRUD action AFTER migration, timeline was inserted', async () => { + await agent.getPreferences() + const feed = { + type: 'feed', + value: feedUri(), + pinned: false, + } + await agent.addSavedFeeds([feed]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + { + ...feed, + id: expect.any(String), + }, + ]) + }) + + // fresh account OR an old account with no v1 prefs to migrate from + it(`brand new user, v1 remains undefined`, async () => { + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ]) + // no v1 prefs to populate from + expect(prefs.feeds).toStrictEqual({ + saved: undefined, + pinned: undefined, + }) + }) + + it(`brand new user, v2 does not write to v1`, async () => { + const a = feedUri() + // migration happens + await agent.getPreferences() + await agent.addSavedFeeds([ + { + type: 'feed', + value: a, + pinned: false, + }, + ]) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + { + id: expect.any(String), + type: 'feed', + value: a, + pinned: false, + }, + ]) + // no v1 prefs to populate from + expect(prefs.feeds).toStrictEqual({ + saved: undefined, + pinned: undefined, + }) + }) + + it(`existing user with v1 prefs, migrates`, async () => { + const one = feedUri() + const two = feedUri() + await agent.app.bsky.actor.putPreferences({ + preferences: [ + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [one], + saved: [one, two], + }, + ], + }) + const prefs = await agent.getPreferences() + + // deprecated interface receives what it normally would + expect(prefs.feeds).toStrictEqual({ + pinned: [one], + saved: [one, two], + }) + // new interface gets new timeline + old pinned feed + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + { + id: expect.any(String), + type: 'feed', + value: one, + pinned: true, + }, + { + id: expect.any(String), + type: 'feed', + value: two, + pinned: false, + }, + ]) + }) + + it('squashes duplicates during migration', async () => { + const one = feedUri() + const two = feedUri() + await agent.app.bsky.actor.putPreferences({ + preferences: [ + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [one, two], + saved: [one, two], + }, + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [], + saved: [], + }, + ], + }) + + // performs migration + const prefs = await agent.getPreferences() + expect(prefs.feeds).toStrictEqual({ + pinned: [], + saved: [], + }) + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ]) + + const res = await agent.app.bsky.actor.getPreferences() + expect(res.data.preferences).toStrictEqual([ + { + $type: 'app.bsky.actor.defs#savedFeedsPrefV2', + items: [ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ], + }, + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [], + saved: [], + }, + ]) + }) + + it('v2 writes persist to v1, not the inverse', async () => { + const a = feedUri() + const b = feedUri() + const c = feedUri() + const d = feedUri() + const e = feedUri() + + await agent.app.bsky.actor.putPreferences({ + preferences: [ + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [a, b], + saved: [a, b], + }, + ], + }) + + // client updates, migrates to v2 + // a and b are both pinned + await agent.getPreferences() + + // new write to v2, c is saved + await agent.addSavedFeeds([ + { + type: 'feed', + value: c, + pinned: false, + }, + ]) + + // v2 write wrote to v1 also + const res1 = await agent.app.bsky.actor.getPreferences() + const v1Pref = res1.data.preferences.find((p) => + AppBskyActorDefs.isSavedFeedsPref(p), + ) + expect(v1Pref).toStrictEqual({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [a, b], + saved: [a, b, c], + }) + + // v1 write occurs, d is added but not to v2 + await agent.addSavedFeed(d) + + const res3 = await agent.app.bsky.actor.getPreferences() + const v1Pref3 = res3.data.preferences.find((p) => + AppBskyActorDefs.isSavedFeedsPref(p), + ) + expect(v1Pref3).toStrictEqual({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [a, b], + saved: [a, b, c, d], + }) + + // another new write to v2, pins e + await agent.addSavedFeeds([ + { + type: 'feed', + value: e, + pinned: true, + }, + ]) + + const res4 = await agent.app.bsky.actor.getPreferences() + const v1Pref4 = res4.data.preferences.find((p) => + AppBskyActorDefs.isSavedFeedsPref(p), + ) + // v1 pref got v2 write + expect(v1Pref4).toStrictEqual({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: [a, b, e], + saved: [a, b, c, d, e], + }) + + const final = await agent.getPreferences() + // d not here bc it was written with v1 + expect(final.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + { id: expect.any(String), type: 'feed', value: a, pinned: true }, + { id: expect.any(String), type: 'feed', value: b, pinned: true }, + { id: expect.any(String), type: 'feed', value: e, pinned: true }, + { id: expect.any(String), type: 'feed', value: c, pinned: false }, + ]) + }) + + it(`filters out invalid values in v1 prefs`, async () => { + // v1 prefs must be valid AtUris, but they could be any type in theory + await agent.app.bsky.actor.putPreferences({ + preferences: [ + { + $type: 'app.bsky.actor.defs#savedFeedsPref', + pinned: ['at://did:plc:fake/app.bsky.graph.follow/fake'], + saved: ['at://did:plc:fake/app.bsky.graph.follow/fake'], + }, + ], + }) + const prefs = await agent.getPreferences() + expect(prefs.savedFeeds).toStrictEqual([ + { + id: expect.any(String), + type: 'timeline', + value: 'following', + pinned: true, + }, + ]) + }) + }) + // end }) }) diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index e8a7c86cc1d..46bd6268a01 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -53,6 +53,7 @@ describe('agent', () => { pinned: undefined, saved: undefined, }, + savedFeeds: expect.any(Array), interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -98,6 +99,7 @@ describe('agent', () => { expect(agent.labelersHeader).toStrictEqual(['did:plc:other']) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: expect.any(Array), interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -133,6 +135,7 @@ describe('agent', () => { expect(agent.labelersHeader).toStrictEqual([]) await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: expect.any(Array), interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, @@ -177,6 +180,7 @@ describe('agent', () => { await expect(agent.getPreferences()).resolves.toStrictEqual({ feeds: { pinned: undefined, saved: undefined }, + savedFeeds: expect.any(Array), interests: { tags: [] }, moderationPrefs: { adultContentEnabled: false, diff --git a/packages/aws/src/s3.ts b/packages/aws/src/s3.ts index 46d82c0966e..81014d63f7d 100644 --- a/packages/aws/src/s3.ts +++ b/packages/aws/src/s3.ts @@ -5,7 +5,7 @@ import { randomStr } from '@atproto/crypto' import { CID } from 'multiformats/cid' import stream from 'stream' -export type S3Config = { bucket: string } & Omit< +export type S3Config = { bucket: string; uploadTimeoutMs?: number } & Omit< aws.S3ClientConfig, 'apiVersion' > @@ -16,13 +16,15 @@ export type S3Config = { bucket: string } & Omit< export class S3BlobStore implements BlobStore { private client: aws.S3 private bucket: string + private uploadTimeoutMs: number constructor( public did: string, cfg: S3Config, ) { - const { bucket, ...rest } = cfg + const { bucket, uploadTimeoutMs, ...rest } = cfg this.bucket = bucket + this.uploadTimeoutMs = uploadTimeoutMs ?? 10000 this.client = new aws.S3({ ...rest, apiVersion: '2006-03-01', @@ -53,12 +55,13 @@ export class S3BlobStore implements BlobStore { async putTemp(bytes: Uint8Array | stream.Readable): Promise { const key = this.genKey() + // @NOTE abort results in error from aws-sdk "Upload aborted." with name "AbortError" const abortController = new AbortController() const timeout = setTimeout( - () => abortController.abort('upload timed out'), - 10000, + () => abortController.abort(), + this.uploadTimeoutMs, ) - await new Upload({ + const upload = new Upload({ client: this.client, params: { Bucket: this.bucket, @@ -67,8 +70,12 @@ export class S3BlobStore implements BlobStore { }, // @ts-ignore native implementation fine in node >=15 abortController, - }).done() - clearTimeout(timeout) + }) + try { + await upload.done() + } finally { + clearTimeout(timeout) + } return key } @@ -89,14 +96,27 @@ export class S3BlobStore implements BlobStore { cid: CID, bytes: Uint8Array | stream.Readable, ): Promise { - await new Upload({ + // @NOTE abort results in error from aws-sdk "Upload aborted." with name "AbortError" + const abortController = new AbortController() + const timeout = setTimeout( + () => abortController.abort(), + this.uploadTimeoutMs, + ) + const upload = new Upload({ client: this.client, params: { Bucket: this.bucket, Body: bytes, Key: this.getStoredPath(cid), }, - }).done() + // @ts-ignore native implementation fine in node >=15 + abortController, + }) + try { + await upload.done() + } finally { + clearTimeout(timeout) + } } async quarantine(cid: CID): Promise { diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 9b85ece6570..308c9ac8870 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,40 @@ # @atproto/bsky +## 0.0.49 + +### Patch Changes + +- Updated dependencies [[`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933)]: + - @atproto/api@0.12.7 + +## 0.0.48 + +### Patch Changes + +- Updated dependencies [[`b9b7c5821`](https://github.com/bluesky-social/atproto/commit/b9b7c582199d57d2fe0af8af5c8c411ed34f5b9d)]: + - @atproto/api@0.12.6 + +## 0.0.47 + +### Patch Changes + +- Updated dependencies [[`3424a1770`](https://github.com/bluesky-social/atproto/commit/3424a17703891f5678ec76ef97e696afb3288b22)]: + - @atproto/api@0.12.5 + +## 0.0.46 + +### Patch Changes + +- Updated dependencies [[`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05)]: + - @atproto/api@0.12.4 + +## 0.0.45 + +### Patch Changes + +- Updated dependencies [[`0edef0ec0`](https://github.com/bluesky-social/atproto/commit/0edef0ec01403fd6097a4d2875b68313f2f1261f), [`c6d758b8b`](https://github.com/bluesky-social/atproto/commit/c6d758b8b63f4ef50b2ab9afc62164e92a53e7f0)]: + - @atproto/api@0.12.3 + ## 0.0.44 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 482be450ba4..56a7f86fbbc 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.44", + "version": "0.0.49", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index da86489a69d..34add3f7926 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,4 +1,4 @@ -import { mapDefined } from '@atproto/common' +import { mapDefined, noUndefinedVals } from '@atproto/common' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions' @@ -12,6 +12,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { parseString } from '../../../../hydration/util' import { resHeaders } from '../../../util' +import AtpAgent from '@atproto/api' export default function (server: Server, ctx: AppContext) { const getSuggestions = createPipeline( @@ -26,12 +27,26 @@ export default function (server: Server, ctx: AppContext) { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers }) - const result = await getSuggestions({ ...params, hydrateCtx }, ctx) - + const headers = noUndefinedVals({ + 'accept-language': req.headers['accept-language'], + 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics']) + ? req.headers['x-bsky-topics'].join(',') + : req.headers['x-bsky-topics'], + }) + const { resHeaders: resultHeaders, ...result } = await getSuggestions( + { ...params, hydrateCtx, headers }, + ctx, + ) + const suggestionsResHeaders = noUndefinedVals({ + 'content-language': resultHeaders?.['content-language'], + }) return { encoding: 'application/json', body: result, - headers: resHeaders({ labelers: hydrateCtx.labelers }), + headers: { + ...suggestionsResHeaders, + ...resHeaders({ labelers: hydrateCtx.labelers }), + }, } }, }) @@ -43,21 +58,38 @@ const skeleton = async (input: { }): 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, + if (ctx.suggestionsAgent) { + const res = + await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton( + { + viewer: viewer ?? undefined, + limit: params.limit, + cursor: params.cursor, + }, + { headers: params.headers }, + ) + return { + dids: res.data.actors.map((a) => a.did), + cursor: res.data.cursor, + resHeaders: res.headers, + } + } else { + // @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, }) - dids = dids.filter((did, i) => !follows.uris[i] && did !== viewer) + 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) } } - return { dids, cursor: parseString(suggestions.cursor) } } const hydration = async (input: { @@ -97,10 +129,12 @@ const presentation = (input: { return { actors, cursor: skeleton.cursor, + resHeaders: skeleton.resHeaders, } } type Context = { + suggestionsAgent: AtpAgent | undefined dataplane: DataPlaneClient hydrator: Hydrator views: Views @@ -108,6 +142,11 @@ type Context = { type Params = QueryParams & { hydrateCtx: HydrateCtx + headers: Record } -type Skeleton = { dids: string[]; cursor?: string } +type Skeleton = { + dids: string[] + cursor?: string + resHeaders?: Record +} diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index c54a72f3f1d..4fe4c54b82e 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -57,6 +57,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { q: term, cursor: params.cursor, limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, }) return { dids: res.actors.map(({ did }) => did), diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index ce64ab49648..0667fbb645a 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -56,6 +56,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { typeahead: true, q: term, limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, }) return { dids: res.actors.map(({ did }) => did), diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index b4c85ad7f2c..03693482872 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -46,6 +46,9 @@ export default function (server: Server, ctx: AppContext) { const headers = noUndefinedVals({ authorization: req.headers['authorization'], 'accept-language': req.headers['accept-language'], + 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics']) + ? req.headers['x-bsky-topics'].join(',') + : req.headers['x-bsky-topics'], }) // @NOTE feed cursors should not be affected by appview swap const { @@ -82,7 +85,7 @@ const skeleton = async ( return { cursor, - items: algoItems.map(toFeedItem), + items: algoItems, timerSkele: timerSkele.stop(), timerHydr: new ServerTimer('hydr').start(), resHeaders, @@ -111,7 +114,8 @@ const noBlocksOrMutes = (inputs: RulesFnInput) => { !bam.authorBlocked && !bam.authorMuted && !bam.originatorBlocked && - !bam.originatorMuted + !bam.originatorMuted && + !bam.parentAuthorBlocked ) }) return skeleton @@ -122,7 +126,12 @@ const presentation = ( ) => { const { ctx, params, skeleton, hydration } = inputs const feed = mapDefined(skeleton.items, (item) => { - return ctx.views.feedViewPost(item, hydration) + const post = ctx.views.feedViewPost(item, hydration) + if (!post) return + return { + ...post, + feedContext: item.feedContext, + } }).slice(0, params.limit) return { feed, @@ -142,7 +151,7 @@ type Params = GetFeedParams & { } type Skeleton = { - items: FeedItem[] + items: AlgoResponseItem[] passthrough: Record // pass through additional items in feedgen response resHeaders?: Record cursor?: string @@ -224,9 +233,12 @@ const skeletonFromFeedGen = async ( const { feed: feedSkele, ...skele } = skeleton const feedItems = feedSkele.map((item) => ({ - itemUri: - typeof item.reason?.repost === 'string' ? item.reason.repost : item.post, - postUri: item.post, + post: { uri: item.post }, + repost: + typeof item.reason?.repost === 'string' + ? { uri: item.reason.repost } + : undefined, + feedContext: item.feedContext, })) return { ...skele, resHeaders, feedItems } @@ -238,15 +250,6 @@ export type AlgoResponse = { cursor?: string } -export type AlgoResponseItem = { - itemUri: string - postUri: string +export type AlgoResponseItem = FeedItem & { + feedContext?: 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/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 5250865cfc2..07940f04374 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -86,7 +86,8 @@ const noBlocksOrMutes = (inputs: { !bam.authorBlocked && !bam.authorMuted && !bam.originatorBlocked && - !bam.originatorMuted + !bam.originatorMuted && + !bam.parentAuthorBlocked ) }) return skeleton diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 8e5dc488c33..5b67c39835c 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -89,7 +89,8 @@ const noBlocksOrMutes = (inputs: { !bam.authorBlocked && !bam.authorMuted && !bam.originatorBlocked && - !bam.originatorMuted + !bam.originatorMuted && + !bam.parentAuthorBlocked ) }) return skeleton diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index ac7268dbba7..7f854e00d40 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -50,6 +50,16 @@ const skeleton = async (inputs: SkeletonFnInput) => { q: params.q, cursor: params.cursor, limit: params.limit, + author: params.author, + domain: params.domain, + lang: params.lang, + mentions: params.mentions, + since: params.since, + sort: params.sort, + tag: params.tag, + until: params.until, + url: params.url, + viewer: params.hydrateCtx.viewer ?? undefined, }) return { posts: res.posts.map(({ uri }) => uri), diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 3518f7b42a9..96f9a804d71 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -20,6 +20,8 @@ export interface ServerConfigValues { courierHttpVersion?: '1.1' | '2' courierIgnoreBadTls?: boolean searchUrl?: string + suggestionsUrl?: string + suggestionsApiKey?: string cdnUrl?: string blobRateLimitBypassKey?: string blobRateLimitBypassHostname?: string @@ -55,6 +57,8 @@ export class ServerConfig { process.env.BSKY_SEARCH_URL || process.env.BSKY_SEARCH_ENDPOINT || undefined + const suggestionsUrl = process.env.BSKY_SUGGESTIONS_URL || undefined + const suggestionsApiKey = process.env.BSKY_SUGGESTIONS_API_KEY || undefined let dataplaneUrls = overrides?.dataplaneUrls dataplaneUrls ??= process.env.BSKY_DATAPLANE_URLS ? process.env.BSKY_DATAPLANE_URLS.split(',') @@ -104,6 +108,8 @@ export class ServerConfig { dataplaneHttpVersion, dataplaneIgnoreBadTls, searchUrl, + suggestionsUrl, + suggestionsApiKey, didPlcUrl, labelsFromIssuerDids, handleResolveNameservers, @@ -206,6 +212,14 @@ export class ServerConfig { return this.cfg.searchUrl } + get suggestionsUrl() { + return this.cfg.suggestionsUrl + } + + get suggestionsApiKey() { + return this.cfg.suggestionsApiKey + } + get cdnUrl() { return this.cfg.cdnUrl } diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index e8a15f5197a..1e49833bf5f 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -24,6 +24,7 @@ export class AppContext { cfg: ServerConfig dataplane: DataPlaneClient searchAgent: AtpAgent | undefined + suggestionsAgent: AtpAgent | undefined hydrator: Hydrator views: Views signingKey: Keypair @@ -46,6 +47,10 @@ export class AppContext { return this.opts.searchAgent } + get suggestionsAgent(): AtpAgent | undefined { + return this.opts.suggestionsAgent + } + get hydrator(): Hydrator { return this.opts.hydrator } diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts index 6236d9e3a8a..4fa7ee80896 100644 --- a/packages/bsky/src/hydration/hydrator.ts +++ b/packages/bsky/src/hydration/hydrator.ts @@ -55,7 +55,7 @@ import { ParsedLabelers } from '../util' export class HydrateCtx { labelers = this.vals.labelers - viewer = this.vals.viewer + viewer = this.vals.viewer !== null ? serviceRefToDid(this.vals.viewer) : null includeTakedowns = this.vals.includeTakedowns constructor(private vals: HydrateCtxVals) {} copy>(vals?: V): HydrateCtx & V { @@ -685,6 +685,12 @@ export class Hydrator { } } +// service refs may look like "did:plc:example#service_id". we want to extract the did part "did:plc:example". +const serviceRefToDid = (serviceRef: string) => { + const idx = serviceRef.indexOf('#') + return idx !== -1 ? serviceRef.slice(0, idx) : serviceRef +} + const listUrisFromProfileViewer = (item: ProfileViewerState | null) => { const listUris: string[] = [] if (item?.mutedByList) { diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index b91ff91922c..b6e2f3d7516 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -74,6 +74,17 @@ export class BskyAppView { const searchAgent = config.searchUrl ? new AtpAgent({ service: config.searchUrl }) : undefined + + const suggestionsAgent = config.suggestionsUrl + ? new AtpAgent({ service: config.suggestionsUrl }) + : undefined + if (suggestionsAgent && config.suggestionsApiKey) { + suggestionsAgent.api.setHeader( + 'authorization', + `Bearer ${config.suggestionsApiKey}`, + ) + } + const dataplane = createDataPlaneClient(config.dataplaneUrls, { httpVersion: config.dataplaneHttpVersion, rejectUnauthorized: !config.dataplaneIgnoreBadTls, @@ -107,6 +118,7 @@ export class BskyAppView { cfg: config, dataplane, searchAgent, + suggestionsAgent, hydrator, views, signingKey, diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index cca3d63041d..03dfee813d8 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -125,6 +125,7 @@ import * as AppBskyNotificationListNotifications from './types/app/bsky/notifica import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/registerPush' import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton' 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' @@ -1648,6 +1649,17 @@ export class AppBskyUnspeccedNS { return this._server.xrpc.method(nsid, cfg) } + getSuggestionsSkeleton( + cfg: ConfigOf< + AV, + AppBskyUnspeccedGetSuggestionsSkeleton.Handler>, + AppBskyUnspeccedGetSuggestionsSkeleton.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.unspecced.getSuggestionsSkeleton' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getTaggedSuggestions( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 41185a8c5fe..dcd391520d8 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2209,6 +2209,9 @@ export const schemaDict = { password: { type: 'string', }, + authFactorToken: { + type: 'string', + }, }, }, }, @@ -2241,6 +2244,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, }, }, }, @@ -2248,6 +2254,9 @@ export const schemaDict = { { name: 'AccountTakedown', }, + { + name: 'AuthFactorTokenRequired', + }, ], }, }, @@ -2568,6 +2577,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, didDoc: { type: 'unknown', }, @@ -2837,6 +2849,9 @@ export const schemaDict = { email: { type: 'string', }, + emailAuthFactor: { + type: 'boolean', + }, token: { type: 'string', description: @@ -3807,6 +3822,7 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#adultContentPref', 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', + 'lex:app.bsky.actor.defs#savedFeedsPrefV2', 'lex:app.bsky.actor.defs#personalDetailsPref', 'lex:app.bsky.actor.defs#feedViewPref', 'lex:app.bsky.actor.defs#threadViewPref', @@ -3845,6 +3861,38 @@ export const schemaDict = { }, }, }, + savedFeed: { + type: 'object', + required: ['id', 'type', 'value', 'pinned'], + properties: { + id: { + type: 'string', + }, + type: { + type: 'string', + knownValues: ['feed', 'list', 'timeline'], + }, + value: { + type: 'string', + }, + pinned: { + type: 'boolean', + }, + }, + }, + savedFeedsPrefV2: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#savedFeed', + }, + }, + }, + }, savedFeedsPref: { type: 'object', required: ['pinned', 'saved'], @@ -4307,12 +4355,6 @@ export const schemaDict = { type: 'string', description: 'Search query prefix; not a full query string.', }, - viewer: { - type: 'string', - format: 'did', - description: - 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', - }, limit: { type: 'integer', minimum: 1, @@ -7849,6 +7891,56 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetSuggestionsSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestionsSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested actors. Intended to be called and then hydrated through app.bsky.actor.getSuggestions', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + cursor: { + type: 'string', + }, + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyUnspeccedGetTaggedSuggestions: { lexicon: 1, id: 'app.bsky.unspecced.getTaggedSuggestions', @@ -8250,6 +8342,8 @@ export const ids = { AppBskyUnspeccedDefs: 'app.bsky.unspecced.defs', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetSuggestionsSkeleton: + 'app.bsky.unspecced.getSuggestionsSkeleton', AppBskyUnspeccedGetTaggedSuggestions: 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedSearchActorsSkeleton: diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts index 7c8a13972fc..891a78edd9a 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts @@ -132,6 +132,7 @@ export type Preferences = ( | AdultContentPref | ContentLabelPref | SavedFeedsPref + | SavedFeedsPrefV2 | PersonalDetailsPref | FeedViewPref | ThreadViewPref @@ -178,6 +179,43 @@ export function validateContentLabelPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v) } +export interface SavedFeed { + id: string + type: 'feed' | 'list' | 'timeline' | (string & {}) + value: string + pinned: boolean + [k: string]: unknown +} + +export function isSavedFeed(v: unknown): v is SavedFeed { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeed' + ) +} + +export function validateSavedFeed(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeed', v) +} + +export interface SavedFeedsPrefV2 { + items: SavedFeed[] + [k: string]: unknown +} + +export function isSavedFeedsPrefV2(v: unknown): v is SavedFeedsPrefV2 { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeedsPrefV2' + ) +} + +export function validateSavedFeedsPrefV2(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeedsPrefV2', v) +} + export interface SavedFeedsPref { pinned: string[] saved: string[] diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts index 65878f7b12f..0198b23d790 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts @@ -14,8 +14,6 @@ export interface QueryParams { term?: string /** Search query prefix; not a full query string. */ q?: string - /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ - viewer?: string limit: number } diff --git a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts b/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts new file mode 100644 index 00000000000..6a18a56358c --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as AppBskyUnspeccedDefs from './defs' + +export interface QueryParams { + /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ + viewer?: string + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + actors: AppBskyUnspeccedDefs.SkeletonSearchActor[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts index 3952959fe5e..a766391a971 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts @@ -14,6 +14,7 @@ export interface InputSchema { /** Handle or other identifier supported by the server for the authenticating user. */ identifier: string password: string + authFactorToken?: string [k: string]: unknown } @@ -25,6 +26,7 @@ export interface OutputSchema { didDoc?: {} email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean [k: string]: unknown } @@ -42,7 +44,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string - error?: 'AccountTakedown' + error?: 'AccountTakedown' | 'AuthFactorTokenRequired' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts index 5a8c40b947e..a12a6a7af5f 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts @@ -17,6 +17,7 @@ export interface OutputSchema { did: string email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean didDoc?: {} [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts index 5473d7571e9..34fc7421979 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -12,6 +12,7 @@ export interface QueryParams {} export interface InputSchema { email: string + emailAuthFactor?: boolean /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ token?: string [k: string]: unknown diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts index c5c573ca97c..41e720a2e05 100644 --- a/packages/bsky/src/views/index.ts +++ b/packages/bsky/src/views/index.ts @@ -334,16 +334,23 @@ export class Views { originatorBlocked: boolean authorMuted: boolean authorBlocked: boolean + parentAuthorBlocked: boolean } { const authorDid = creatorFromUri(item.post.uri) const originatorDid = item.repost ? creatorFromUri(item.repost.uri) : authorDid + const post = state.posts?.get(item.post.uri) + const parentUri = post?.record.reply?.parent.uri + const parentAuthorDid = parentUri && creatorFromUri(parentUri) return { originatorMuted: this.viewerMuteExists(originatorDid, state), originatorBlocked: this.viewerBlockExists(originatorDid, state), authorMuted: this.viewerMuteExists(authorDid, state), authorBlocked: this.viewerBlockExists(authorDid, state), + parentAuthorBlocked: parentAuthorDid + ? this.viewerBlockExists(parentAuthorDid, state) + : false, } } diff --git a/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap b/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap index 0c14e2af095..967e22c3f4d 100644 --- a/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap +++ b/packages/bsky/tests/__snapshots__/feed-generation.test.ts.snap @@ -342,6 +342,7 @@ Array [ exports[`feed generation getFeed paginates, handling replies and reposts. 1`] = ` Array [ Object { + "feedContext": "item-0", "post": Object { "author": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", @@ -401,6 +402,7 @@ Array [ }, }, Object { + "feedContext": "item-1", "post": Object { "author": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", @@ -435,6 +437,7 @@ Array [ }, }, Object { + "feedContext": "item-2", "post": Object { "author": Object { "did": "user(4)", @@ -554,6 +557,7 @@ Array [ }, }, Object { + "feedContext": "item-4", "post": Object { "author": Object { "did": "user(4)", @@ -678,6 +682,7 @@ Array [ }, }, Object { + "feedContext": "item-5", "post": Object { "author": Object { "did": "user(6)", @@ -865,6 +870,7 @@ Array [ exports[`feed generation getFeed resolves basic feed contents without auth. 1`] = ` Array [ Object { + "feedContext": "item-0", "post": Object { "author": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", @@ -919,6 +925,7 @@ Array [ }, }, Object { + "feedContext": "item-2", "post": Object { "author": Object { "did": "user(2)", @@ -1023,6 +1030,7 @@ Array [ }, }, Object { + "feedContext": "item-4", "post": Object { "author": Object { "did": "user(2)", @@ -1135,6 +1143,7 @@ Array [ exports[`feed generation getFeed resolves basic feed contents. 1`] = ` Array [ Object { + "feedContext": "item-0", "post": Object { "author": Object { "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", @@ -1194,6 +1203,7 @@ Array [ }, }, Object { + "feedContext": "item-2", "post": Object { "author": Object { "did": "user(2)", @@ -1313,6 +1323,7 @@ Array [ }, }, Object { + "feedContext": "item-4", "post": Object { "author": Object { "did": "user(2)", diff --git a/packages/bsky/tests/feed-generation.test.ts b/packages/bsky/tests/feed-generation.test.ts index 3120f45669c..df3d5a1b9e8 100644 --- a/packages/bsky/tests/feed-generation.test.ts +++ b/packages/bsky/tests/feed-generation.test.ts @@ -543,7 +543,7 @@ describe('feed generation', () => { repost: sc.reposts[sc.dids.carol][0].uriStr, }, }, - ] + ].map((item, i) => ({ ...item, feedContext: `item-${i}` })) // add a deterministic context to test passthrough const offset = cursor ? parseInt(cursor, 10) : 0 const fullFeed = candidates.filter((_, i) => { if (feedName === 'even') { diff --git a/packages/bsky/tests/views/__snapshots__/actor-search.test.ts.snap b/packages/bsky/tests/views/__snapshots__/actor-search.test.ts.snap index a6990b60ed3..7e134968d0b 100644 --- a/packages/bsky/tests/views/__snapshots__/actor-search.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/actor-search.test.ts.snap @@ -36,7 +36,7 @@ Array [ "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "did": "user(4)", "displayName": "Latoya Windler", - "handle": "carolina-mcdermott77.test", + "handle": "carolina-mcderm77.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { @@ -115,7 +115,7 @@ Array [ "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "did": "user(4)", "displayName": "Latoya Windler", - "handle": "carolina-mcdermott77.test", + "handle": "carolina-mcderm77.test", "viewer": Object { "blockedBy": false, "muted": false, diff --git a/packages/bsky/tests/views/actor-search.test.ts b/packages/bsky/tests/views/actor-search.test.ts index e7c4390edc2..8a689af447c 100644 --- a/packages/bsky/tests/views/actor-search.test.ts +++ b/packages/bsky/tests/views/actor-search.test.ts @@ -61,7 +61,7 @@ describe.skip('pds actor search views', () => { 'shane-torphy52.test', // Sadie Carter 'aliya-hodkiewicz.test', // Carlton Abernathy IV 'carlos6.test', - 'carolina-mcdermott77.test', + 'carolina-mcderm77.test', ] shouldContain.forEach((handle) => expect(handles).toContain(handle)) @@ -150,7 +150,7 @@ describe.skip('pds actor search views', () => { 'shane-torphy52.test', // Sadie Carter 'aliya-hodkiewicz.test', // Carlton Abernathy IV 'carlos6.test', - 'carolina-mcdermott77.test', + 'carolina-mcderm77.test', ] shouldContain.forEach((handle) => expect(handles).toContain(handle)) @@ -246,7 +246,7 @@ describe.skip('pds actor search views', () => { ) const handles = result.data.actors.map((u) => u.handle) expect(handles).toContain('carlos6.test') - expect(handles).toContain('carolina-mcdermott77.test') + expect(handles).toContain('carolina-mcderm77.test') expect(handles).not.toContain('cara-wiegand69.test') }) }) diff --git a/packages/bsky/tests/views/blocks.test.ts b/packages/bsky/tests/views/blocks.test.ts index 84d82fce51b..2e7759d8157 100644 --- a/packages/bsky/tests/views/blocks.test.ts +++ b/packages/bsky/tests/views/blocks.test.ts @@ -160,8 +160,14 @@ describe('pds views with blocking', () => { { limit: 100 }, { headers: await network.serviceHeaders(carol) }, ) + + // dan's posts don't appear, nor alice's reply to dan. expect( - resCarol.data.feed.some((post) => post.post.author.did === dan), + resCarol.data.feed.some( + (post) => + post.post.author.did === dan || + post.reply?.parent.author?.['did'] === dan, + ), ).toBeFalsy() const resDan = await agent.api.app.bsky.feed.getTimeline( @@ -169,7 +175,11 @@ describe('pds views with blocking', () => { { headers: await network.serviceHeaders(dan) }, ) expect( - resDan.data.feed.some((post) => post.post.author.did === carol), + resDan.data.feed.some( + (post) => + post.post.author.did === carol || + post.reply?.parent.author?.['did'] === carol, + ), ).toBeFalsy() }) diff --git a/packages/dev-env/CHANGELOG.md b/packages/dev-env/CHANGELOG.md index 14a416a9f16..4fedfd8ac51 100644 --- a/packages/dev-env/CHANGELOG.md +++ b/packages/dev-env/CHANGELOG.md @@ -1,5 +1,55 @@ # @atproto/dev-env +## 0.3.9 + +### Patch Changes + +- Updated dependencies [[`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933)]: + - @atproto/ozone@0.1.11 + - @atproto/api@0.12.7 + - @atproto/pds@0.4.18 + - @atproto/bsky@0.0.49 + +## 0.3.8 + +### Patch Changes + +- Updated dependencies [[`b9b7c5821`](https://github.com/bluesky-social/atproto/commit/b9b7c582199d57d2fe0af8af5c8c411ed34f5b9d)]: + - @atproto/api@0.12.6 + - @atproto/bsky@0.0.48 + - @atproto/ozone@0.1.10 + - @atproto/pds@0.4.17 + +## 0.3.7 + +### Patch Changes + +- Updated dependencies [[`3424a1770`](https://github.com/bluesky-social/atproto/commit/3424a17703891f5678ec76ef97e696afb3288b22)]: + - @atproto/api@0.12.5 + - @atproto/bsky@0.0.47 + - @atproto/ozone@0.1.9 + - @atproto/pds@0.4.16 + +## 0.3.6 + +### Patch Changes + +- Updated dependencies [[`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05)]: + - @atproto/api@0.12.4 + - @atproto/pds@0.4.15 + - @atproto/bsky@0.0.46 + - @atproto/ozone@0.1.8 + +## 0.3.5 + +### Patch Changes + +- Updated dependencies [[`0edef0ec0`](https://github.com/bluesky-social/atproto/commit/0edef0ec01403fd6097a4d2875b68313f2f1261f), [`c6d758b8b`](https://github.com/bluesky-social/atproto/commit/c6d758b8b63f4ef50b2ab9afc62164e92a53e7f0)]: + - @atproto/api@0.12.3 + - @atproto/bsky@0.0.45 + - @atproto/ozone@0.1.7 + - @atproto/pds@0.4.14 + ## 0.3.4 ### Patch Changes diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index a740e194955..0ec71aae8a9 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/dev-env", - "version": "0.3.4", + "version": "0.3.9", "license": "MIT", "description": "Local development environment helper for atproto development", "keywords": [ diff --git a/packages/dev-env/src/seed/client.ts b/packages/dev-env/src/seed/client.ts index 542d260c172..8647d474e9a 100644 --- a/packages/dev-env/src/seed/client.ts +++ b/packages/dev-env/src/seed/client.ts @@ -6,6 +6,7 @@ import AtpAgent, { AppBskyRichtextFacet, AppBskyFeedLike, AppBskyGraphFollow, + AppBskyGraphList, } from '@atproto/api' import { AtUri } from '@atproto/syntax' import { BlobRef } from '@atproto/lexicon' @@ -399,7 +400,12 @@ export class SeedClient< return repost } - async createList(by: string, name: string, purpose: 'mod' | 'curate') { + async createList( + by: string, + name: string, + purpose: 'mod' | 'curate', + overrides?: Partial, + ) { const res = await this.agent.api.app.bsky.graph.list.create( { repo: by }, { @@ -409,6 +415,7 @@ export class SeedClient< ? 'app.bsky.graph.defs#modlist' : 'app.bsky.graph.defs#curatelist', createdAt: new Date().toISOString(), + ...(overrides || {}), }, this.getHeaders(by), ) diff --git a/packages/dev-env/src/seed/users-bulk.ts b/packages/dev-env/src/seed/users-bulk.ts index 5a6cc42981b..1c0f4206e9a 100644 --- a/packages/dev-env/src/seed/users-bulk.ts +++ b/packages/dev-env/src/seed/users-bulk.ts @@ -98,14 +98,14 @@ const users = [ { handle: 'kiana-schmitt39.test', displayName: null }, { handle: 'rhianna-stamm29.test', displayName: null }, { handle: 'tiara-mohr.test', displayName: null }, - { handle: 'eleazar-balistreri70.test', displayName: 'Gordon Weissnat' }, + { handle: 'eleazar-balist70.test', displayName: 'Gordon Weissnat' }, { handle: 'bettie-bogisich96.test', displayName: null }, { handle: 'lura-jacobi55.test', displayName: null }, { handle: 'santa-hermann78.test', displayName: 'Melissa Johnson' }, { handle: 'dylan61.test', displayName: null }, { handle: 'ryley-kerluke.test', displayName: 'Alexander Purdy' }, { handle: 'moises-bins8.test', displayName: null }, - { handle: 'angelita-schaefer27.test', displayName: null }, + { handle: 'angelita-schaef27.test', displayName: null }, { handle: 'natasha83.test', displayName: 'Dean Romaguera' }, { handle: 'sydni48.test', displayName: null }, { handle: 'darrion91.test', displayName: 'Jeanette Weimann' }, @@ -182,10 +182,10 @@ const users = [ { handle: 'melyna-zboncak.test', displayName: null }, { handle: 'rowan-parisian.test', displayName: 'Mr. Veronica Feeney' }, { handle: 'lois-blanda20.test', displayName: 'Todd Rolfson' }, - { handle: 'turner-balistreri76.test', displayName: null }, + { handle: 'turner-bali76.test', displayName: null }, { handle: 'dee-hoppe65.test', displayName: null }, { handle: 'nikko-rosenbaum60.test', displayName: 'Joann Gutmann' }, - { handle: 'cornell-romaguera53.test', displayName: null }, + { handle: 'cornell-rom53.test', displayName: null }, { handle: 'zack3.test', displayName: null }, { handle: 'fredrick41.test', displayName: 'Julius Kreiger' }, { handle: 'elwyn62.test', displayName: null }, @@ -223,6 +223,6 @@ const users = [ { handle: 'nayeli-koss73.test', displayName: 'Johnny Lang' }, { handle: 'cara-wiegand69.test', displayName: null }, { handle: 'gideon-ohara51.test', displayName: null }, - { handle: 'carolina-mcdermott77.test', displayName: 'Latoya Windler' }, + { handle: 'carolina-mcderm77.test', displayName: 'Latoya Windler' }, { handle: 'danyka90.test', displayName: 'Hope Kub' }, ] diff --git a/packages/ozone/CHANGELOG.md b/packages/ozone/CHANGELOG.md index cfd2382def2..9ed57b6eda8 100644 --- a/packages/ozone/CHANGELOG.md +++ b/packages/ozone/CHANGELOG.md @@ -1,5 +1,42 @@ # @atproto/ozone +## 0.1.11 + +### Patch Changes + +- [#2390](https://github.com/bluesky-social/atproto/pull/2390) [`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933) Thanks [@foysalit](https://github.com/foysalit)! - Allow muting reports from accounts via `#modEventMuteReporter` event + +- Updated dependencies [[`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933)]: + - @atproto/api@0.12.7 + +## 0.1.10 + +### Patch Changes + +- Updated dependencies [[`b9b7c5821`](https://github.com/bluesky-social/atproto/commit/b9b7c582199d57d2fe0af8af5c8c411ed34f5b9d)]: + - @atproto/api@0.12.6 + +## 0.1.9 + +### Patch Changes + +- Updated dependencies [[`3424a1770`](https://github.com/bluesky-social/atproto/commit/3424a17703891f5678ec76ef97e696afb3288b22)]: + - @atproto/api@0.12.5 + +## 0.1.8 + +### Patch Changes + +- Updated dependencies [[`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05)]: + - @atproto/api@0.12.4 + +## 0.1.7 + +### Patch Changes + +- Updated dependencies [[`0edef0ec0`](https://github.com/bluesky-social/atproto/commit/0edef0ec01403fd6097a4d2875b68313f2f1261f), [`c6d758b8b`](https://github.com/bluesky-social/atproto/commit/c6d758b8b63f4ef50b2ab9afc62164e92a53e7f0)]: + - @atproto/api@0.12.3 + ## 0.1.6 ### Patch Changes diff --git a/packages/ozone/jest.config.js b/packages/ozone/jest.config.js index ee315e79d22..3cf6bcaabe6 100644 --- a/packages/ozone/jest.config.js +++ b/packages/ozone/jest.config.js @@ -1,6 +1,6 @@ /** @type {import('jest').Config} */ module.exports = { - displayName: 'Bsky App View', + displayName: 'Ozone', transform: { '^.+\\.(t|j)s$': '@swc/jest' }, transformIgnorePatterns: [`/node_modules/(?!get-port)`], testTimeout: 60000, diff --git a/packages/ozone/package.json b/packages/ozone/package.json index a33aa86de43..d15e32201ba 100644 --- a/packages/ozone/package.json +++ b/packages/ozone/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/ozone", - "version": "0.1.6", + "version": "0.1.11", "license": "MIT", "description": "Backend service for moderating the Bluesky network.", "keywords": [ @@ -41,6 +41,7 @@ "express": "^4.17.2", "http-terminator": "^3.2.0", "kysely": "^0.22.0", + "lande": "^1.0.10", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "pg": "^8.10.0", diff --git a/packages/ozone/src/api/communication/createTemplate.ts b/packages/ozone/src/api/communication/createTemplate.ts index eb62e9cadc9..4b3576070f7 100644 --- a/packages/ozone/src/api/communication/createTemplate.ts +++ b/packages/ozone/src/api/communication/createTemplate.ts @@ -10,9 +10,9 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db const { createdBy, ...template } = input.body - if (!access.isAdmin) { + if (!access.isModerator) { throw new AuthRequiredError( - 'Must be an admin to create a communication template', + 'Must be a moderator to create a communication template', ) } diff --git a/packages/ozone/src/api/communication/deleteTemplate.ts b/packages/ozone/src/api/communication/deleteTemplate.ts index 20ba3055064..79a05c9a996 100644 --- a/packages/ozone/src/api/communication/deleteTemplate.ts +++ b/packages/ozone/src/api/communication/deleteTemplate.ts @@ -10,9 +10,9 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db const { id } = input.body - if (!access.isAdmin) { + if (!access.isModerator) { throw new AuthRequiredError( - 'Must be an admin to delete a communication template', + 'Must be a moderator to delete a communication template', ) } diff --git a/packages/ozone/src/api/communication/updateTemplate.ts b/packages/ozone/src/api/communication/updateTemplate.ts index b7f2b1cc4a7..d1271f7ecb3 100644 --- a/packages/ozone/src/api/communication/updateTemplate.ts +++ b/packages/ozone/src/api/communication/updateTemplate.ts @@ -10,9 +10,9 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db const { id, updatedBy, ...template } = input.body - if (!access.isAdmin) { + if (!access.isModerator) { throw new AuthRequiredError( - 'Must be an admin to update a communication template', + 'Must be a moderator to update a communication template', ) } diff --git a/packages/ozone/src/api/moderation/emitEvent.ts b/packages/ozone/src/api/moderation/emitEvent.ts index 1d971811673..78fc850f6b0 100644 --- a/packages/ozone/src/api/moderation/emitEvent.ts +++ b/packages/ozone/src/api/moderation/emitEvent.ts @@ -5,8 +5,10 @@ import { isModEventDivert, isModEventEmail, isModEventLabel, + isModEventMuteReporter, isModEventReverseTakedown, isModEventTakedown, + isModEventUnmuteReporter, } from '../../lexicon/types/tools/ozone/moderation/defs' import { HandlerInput } from '../../lexicon/types/tools/ozone/moderation/emitEvent' import { subjectFromInput } from '../../mod-service/subject' @@ -113,6 +115,13 @@ const handleModerationEvent = async ({ await ctx.blobDiverter.uploadBlobOnService(subject.info()) } + if ( + (isModEventMuteReporter(event) || isModEventUnmuteReporter(event)) && + !subject.isRepo() + ) { + throw new InvalidRequestError('Subject must be a repo when muting reporter') + } + const moderationEvent = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.modService(dbTxn) diff --git a/packages/ozone/src/api/moderation/queryStatuses.ts b/packages/ozone/src/api/moderation/queryStatuses.ts index fb433bdead9..5998a0c2390 100644 --- a/packages/ozone/src/api/moderation/queryStatuses.ts +++ b/packages/ozone/src/api/moderation/queryStatuses.ts @@ -20,6 +20,7 @@ export default function (server: Server, ctx: AppContext) { sortDirection = 'desc', sortField = 'lastReportedAt', includeMuted = false, + onlyMuted = false, limit = 50, cursor, tags = [], @@ -37,6 +38,7 @@ export default function (server: Server, ctx: AppContext) { reportedAfter, reportedBefore, includeMuted, + onlyMuted, ignoreSubjects, sortDirection, lastReviewedBy, diff --git a/packages/ozone/src/api/util.ts b/packages/ozone/src/api/util.ts index b9c1de4fb65..e5ed3ee2a46 100644 --- a/packages/ozone/src/api/util.ts +++ b/packages/ozone/src/api/util.ts @@ -112,6 +112,8 @@ const eventTypes = new Set([ 'tools.ozone.moderation.defs#modEventReport', 'tools.ozone.moderation.defs#modEventMute', 'tools.ozone.moderation.defs#modEventUnmute', + 'tools.ozone.moderation.defs#modEventMuteReporter', + 'tools.ozone.moderation.defs#modEventUnmuteReporter', 'tools.ozone.moderation.defs#modEventReverseTakedown', 'tools.ozone.moderation.defs#modEventEmail', 'tools.ozone.moderation.defs#modEventResolveAppeal', diff --git a/packages/ozone/src/db/migrations/20240408T192432676Z-mute-reporting.ts b/packages/ozone/src/db/migrations/20240408T192432676Z-mute-reporting.ts new file mode 100644 index 00000000000..0586cf6874f --- /dev/null +++ b/packages/ozone/src/db/migrations/20240408T192432676Z-mute-reporting.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('moderation_subject_status') + .addColumn('muteReportingUntil', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('muteReportingUntil') + .execute() +} diff --git a/packages/ozone/src/db/migrations/index.ts b/packages/ozone/src/db/migrations/index.ts index 1281f12c7f1..88d31dddcc2 100644 --- a/packages/ozone/src/db/migrations/index.ts +++ b/packages/ozone/src/db/migrations/index.ts @@ -7,3 +7,4 @@ export * as _20240116T085607200Z from './20240116T085607200Z-communication-templ export * as _20240201T051104136Z from './20240201T051104136Z-mod-event-blobs' export * as _20240208T213404429Z from './20240208T213404429Z-add-tags-column-to-moderation-subject' export * as _20240228T003647759Z from './20240228T003647759Z-add-label-sigs' +export * as _20240408T192432676Z from './20240408T192432676Z-mute-reporting' diff --git a/packages/ozone/src/db/schema/moderation_event.ts b/packages/ozone/src/db/schema/moderation_event.ts index aa43bcbe851..e860d52eb22 100644 --- a/packages/ozone/src/db/schema/moderation_event.ts +++ b/packages/ozone/src/db/schema/moderation_event.ts @@ -12,6 +12,9 @@ export interface ModerationEvent { | 'tools.ozone.moderation.defs#modEventLabel' | 'tools.ozone.moderation.defs#modEventReport' | 'tools.ozone.moderation.defs#modEventMute' + | 'tools.ozone.moderation.defs#modEventUnmute' + | 'tools.ozone.moderation.defs#modEventMuteReporter' + | 'tools.ozone.moderation.defs#modEventUnmuteReporter' | 'tools.ozone.moderation.defs#modEventReverseTakedown' | 'tools.ozone.moderation.defs#modEventEmail' | 'tools.ozone.moderation.defs#modEventResolveAppeal' diff --git a/packages/ozone/src/db/schema/moderation_subject_status.ts b/packages/ozone/src/db/schema/moderation_subject_status.ts index dd93f092db6..82438c1dff3 100644 --- a/packages/ozone/src/db/schema/moderation_subject_status.ts +++ b/packages/ozone/src/db/schema/moderation_subject_status.ts @@ -26,6 +26,7 @@ export interface ModerationSubjectStatus { lastReportedAt: string | null lastAppealedAt: string | null muteUntil: string | null + muteReportingUntil: string | null suspendUntil: string | null takendown: boolean appealed: boolean | null diff --git a/packages/ozone/src/lexicon/index.ts b/packages/ozone/src/lexicon/index.ts index f510880f979..08568551580 100644 --- a/packages/ozone/src/lexicon/index.ts +++ b/packages/ozone/src/lexicon/index.ts @@ -125,6 +125,7 @@ import * as AppBskyNotificationListNotifications from './types/app/bsky/notifica import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/registerPush' import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton' 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' @@ -1667,6 +1668,17 @@ export class AppBskyUnspeccedNS { return this._server.xrpc.method(nsid, cfg) } + getSuggestionsSkeleton( + cfg: ConfigOf< + AV, + AppBskyUnspeccedGetSuggestionsSkeleton.Handler>, + AppBskyUnspeccedGetSuggestionsSkeleton.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.unspecced.getSuggestionsSkeleton' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getTaggedSuggestions( cfg: ConfigOf< AV, diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index a41d998eeeb..f2886955a36 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -2209,6 +2209,9 @@ export const schemaDict = { password: { type: 'string', }, + authFactorToken: { + type: 'string', + }, }, }, }, @@ -2241,6 +2244,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, }, }, }, @@ -2248,6 +2254,9 @@ export const schemaDict = { { name: 'AccountTakedown', }, + { + name: 'AuthFactorTokenRequired', + }, ], }, }, @@ -2568,6 +2577,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, didDoc: { type: 'unknown', }, @@ -2837,6 +2849,9 @@ export const schemaDict = { email: { type: 'string', }, + emailAuthFactor: { + type: 'boolean', + }, token: { type: 'string', description: @@ -3807,6 +3822,7 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#adultContentPref', 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', + 'lex:app.bsky.actor.defs#savedFeedsPrefV2', 'lex:app.bsky.actor.defs#personalDetailsPref', 'lex:app.bsky.actor.defs#feedViewPref', 'lex:app.bsky.actor.defs#threadViewPref', @@ -3845,6 +3861,38 @@ export const schemaDict = { }, }, }, + savedFeed: { + type: 'object', + required: ['id', 'type', 'value', 'pinned'], + properties: { + id: { + type: 'string', + }, + type: { + type: 'string', + knownValues: ['feed', 'list', 'timeline'], + }, + value: { + type: 'string', + }, + pinned: { + type: 'boolean', + }, + }, + }, + savedFeedsPrefV2: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#savedFeed', + }, + }, + }, + }, savedFeedsPref: { type: 'object', required: ['pinned', 'saved'], @@ -4307,12 +4355,6 @@ export const schemaDict = { type: 'string', description: 'Search query prefix; not a full query string.', }, - viewer: { - type: 'string', - format: 'did', - description: - 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', - }, limit: { type: 'integer', minimum: 1, @@ -7849,6 +7891,56 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetSuggestionsSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestionsSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested actors. Intended to be called and then hydrated through app.bsky.actor.getSuggestions', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + cursor: { + type: 'string', + }, + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyUnspeccedGetTaggedSuggestions: { lexicon: 1, id: 'app.bsky.unspecced.getTaggedSuggestions', @@ -8316,6 +8408,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8375,6 +8470,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8454,6 +8552,10 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + muteReportingUntil: { + type: 'string', + format: 'datetime', + }, lastReviewedBy: { type: 'string', format: 'did', @@ -8577,6 +8679,11 @@ export const schemaDict = { comment: { type: 'string', }, + isReporterMuted: { + type: 'boolean', + description: + "Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.", + }, reportType: { type: 'ref', ref: 'lex:com.atproto.moderation.defs#reasonType', @@ -8645,6 +8752,30 @@ export const schemaDict = { }, }, }, + modEventMuteReporter: { + type: 'object', + description: 'Mute incoming reports from an account', + required: ['durationInHours'], + properties: { + comment: { + type: 'string', + }, + durationInHours: { + type: 'integer', + description: 'Indicates how long the account should remain muted.', + }, + }, + }, + modEventUnmuteReporter: { + type: 'object', + description: 'Unmute incoming reports from an account', + properties: { + comment: { + type: 'string', + description: 'Describe reasoning behind the reversal.', + }, + }, + }, modEventEmail: { type: 'object', description: 'Keep a log of outgoing email to a user', @@ -9029,6 +9160,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventLabel', 'lex:tools.ozone.moderation.defs#modEventReport', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', 'lex:tools.ozone.moderation.defs#modEventUnmute', 'lex:tools.ozone.moderation.defs#modEventEmail', @@ -9337,6 +9471,11 @@ export const schemaDict = { description: "By default, we don't include muted subjects in the results. Set this to true to include them.", }, + onlyMuted: { + type: 'boolean', + description: + 'When set to true, only muted subjects and reporters will be returned.', + }, reviewState: { type: 'string', description: 'Specify when fetching subjects in a certain state', @@ -9627,6 +9766,8 @@ export const ids = { AppBskyUnspeccedDefs: 'app.bsky.unspecced.defs', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetSuggestionsSkeleton: + 'app.bsky.unspecced.getSuggestionsSkeleton', AppBskyUnspeccedGetTaggedSuggestions: 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedSearchActorsSkeleton: diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts index 7c8a13972fc..891a78edd9a 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts @@ -132,6 +132,7 @@ export type Preferences = ( | AdultContentPref | ContentLabelPref | SavedFeedsPref + | SavedFeedsPrefV2 | PersonalDetailsPref | FeedViewPref | ThreadViewPref @@ -178,6 +179,43 @@ export function validateContentLabelPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v) } +export interface SavedFeed { + id: string + type: 'feed' | 'list' | 'timeline' | (string & {}) + value: string + pinned: boolean + [k: string]: unknown +} + +export function isSavedFeed(v: unknown): v is SavedFeed { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeed' + ) +} + +export function validateSavedFeed(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeed', v) +} + +export interface SavedFeedsPrefV2 { + items: SavedFeed[] + [k: string]: unknown +} + +export function isSavedFeedsPrefV2(v: unknown): v is SavedFeedsPrefV2 { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeedsPrefV2' + ) +} + +export function validateSavedFeedsPrefV2(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeedsPrefV2', v) +} + export interface SavedFeedsPref { pinned: string[] saved: string[] diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts index 65878f7b12f..0198b23d790 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts @@ -14,8 +14,6 @@ export interface QueryParams { term?: string /** Search query prefix; not a full query string. */ q?: string - /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ - viewer?: string limit: number } diff --git a/packages/ozone/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts b/packages/ozone/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts new file mode 100644 index 00000000000..6a18a56358c --- /dev/null +++ b/packages/ozone/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as AppBskyUnspeccedDefs from './defs' + +export interface QueryParams { + /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ + viewer?: string + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + actors: AppBskyUnspeccedDefs.SkeletonSearchActor[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/ozone/src/lexicon/types/com/atproto/server/createSession.ts b/packages/ozone/src/lexicon/types/com/atproto/server/createSession.ts index 3952959fe5e..a766391a971 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/server/createSession.ts @@ -14,6 +14,7 @@ export interface InputSchema { /** Handle or other identifier supported by the server for the authenticating user. */ identifier: string password: string + authFactorToken?: string [k: string]: unknown } @@ -25,6 +26,7 @@ export interface OutputSchema { didDoc?: {} email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean [k: string]: unknown } @@ -42,7 +44,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string - error?: 'AccountTakedown' + error?: 'AccountTakedown' | 'AuthFactorTokenRequired' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/ozone/src/lexicon/types/com/atproto/server/getSession.ts b/packages/ozone/src/lexicon/types/com/atproto/server/getSession.ts index 5a8c40b947e..a12a6a7af5f 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/server/getSession.ts @@ -17,6 +17,7 @@ export interface OutputSchema { did: string email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean didDoc?: {} [k: string]: unknown } diff --git a/packages/ozone/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/ozone/src/lexicon/types/com/atproto/server/updateEmail.ts index 5473d7571e9..34fc7421979 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/server/updateEmail.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -12,6 +12,7 @@ export interface QueryParams {} export interface InputSchema { email: string + emailAuthFactor?: boolean /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ token?: string [k: string]: unknown diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts index b150d735f6c..d5cfd9f5530 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -22,6 +22,9 @@ export interface ModEventView { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -61,6 +64,9 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -105,6 +111,7 @@ export interface SubjectStatusView { /** Sticky comment on the subject. */ comment?: string muteUntil?: string + muteReportingUntil?: string lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string @@ -237,6 +244,8 @@ export function validateModEventComment(v: unknown): ValidationResult { /** Report a subject */ export interface ModEventReport { comment?: string + /** Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject. */ + isReporterMuted?: boolean reportType: ComAtprotoModerationDefs.ReasonType [k: string]: unknown } @@ -346,6 +355,53 @@ export function validateModEventUnmute(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#modEventUnmute', v) } +/** Mute incoming reports from an account */ +export interface ModEventMuteReporter { + comment?: string + /** Indicates how long the account should remain muted. */ + durationInHours: number + [k: string]: unknown +} + +export function isModEventMuteReporter(v: unknown): v is ModEventMuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventMuteReporter' + ) +} + +export function validateModEventMuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventMuteReporter', + v, + ) +} + +/** Unmute incoming reports from an account */ +export interface ModEventUnmuteReporter { + /** Describe reasoning behind the reversal. */ + comment?: string + [k: string]: unknown +} + +export function isModEventUnmuteReporter( + v: unknown, +): v is ModEventUnmuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventUnmuteReporter' + ) +} + +export function validateModEventUnmuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventUnmuteReporter', + v, + ) +} + /** Keep a log of outgoing email to a user */ export interface ModEventEmail { /** The subject line of the email sent to the user. */ diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/emitEvent.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/emitEvent.ts index e3f502bd2eb..0b8737ccafd 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/emitEvent.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/emitEvent.ts @@ -22,6 +22,9 @@ export interface InputSchema { | ToolsOzoneModerationDefs.ModEventLabel | ToolsOzoneModerationDefs.ModEventReport | ToolsOzoneModerationDefs.ModEventMute + | ToolsOzoneModerationDefs.ModEventUnmute + | ToolsOzoneModerationDefs.ModEventMuteReporter + | ToolsOzoneModerationDefs.ModEventUnmuteReporter | ToolsOzoneModerationDefs.ModEventReverseTakedown | ToolsOzoneModerationDefs.ModEventUnmute | ToolsOzoneModerationDefs.ModEventEmail diff --git a/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts b/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts index 188749411fc..aece00e5626 100644 --- a/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +++ b/packages/ozone/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts @@ -23,6 +23,8 @@ export interface QueryParams { reviewedBefore?: string /** By default, we don't include muted subjects in the results. Set this to true to include them. */ includeMuted?: boolean + /** When set to true, only muted subjects and reporters will be returned. */ + onlyMuted?: boolean /** Specify when fetching subjects in a certain state */ reviewState?: string ignoreSubjects?: string[] diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 2ce290cbbce..1bb4c9448f5 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -18,6 +18,7 @@ import { isModEventTakedown, isModEventEmail, isModEventTag, + isModEventUnmute, } from '../lexicon/types/tools/ozone/moderation/defs' import { RepoRef, RepoBlobRef } from '../lexicon/types/com/atproto/admin/defs' import { @@ -310,6 +311,14 @@ export class ModerationService { } } + // Keep trace of reports that came in while the reporter was in muted stated + if (isModEventReport(event)) { + const isReportingMuted = await this.isReportingMutedForSubject(createdBy) + if (isReportingMuted) { + meta.isReporterMuted = true + } + } + const subjectInfo = subject.info() const modEvent = await this.db.db @@ -668,6 +677,7 @@ export class ModerationService { reportedAfter, reportedBefore, includeMuted, + onlyMuted, ignoreSubjects, sortDirection, lastReviewedBy, @@ -686,6 +696,7 @@ export class ModerationService { reportedAfter?: string reportedBefore?: string includeMuted?: boolean + onlyMuted?: boolean subject?: string ignoreSubjects?: string[] sortDirection: 'asc' | 'desc' @@ -757,9 +768,19 @@ export class ModerationService { ) } + if (onlyMuted) { + builder = builder.where((qb) => + qb + .where('muteUntil', '>', new Date().toISOString()) + .orWhere('muteReportingUntil', '>', new Date().toISOString()), + ) + } + if (tags.length) { builder = builder.where( - sql`${ref('moderation_subject_status.tags')} @> ${jsonb(tags)}`, + sql`${ref('moderation_subject_status.tags')} ?| array[${sql.join( + tags, + )}]::TEXT[]`, ) } @@ -767,9 +788,9 @@ export class ModerationService { builder = builder.where((qb) => qb .where( - sql`NOT(${ref('moderation_subject_status.tags')} @> ${jsonb( - excludeTags, - )})`, + sql`NOT(${ref( + 'moderation_subject_status.tags', + )} ?| array[${sql.join(excludeTags)}]::TEXT[])`, ) .orWhere('tags', 'is', null), ) @@ -787,7 +808,6 @@ export class ModerationService { tryIndex: true, nullsLast: true, }) - const results = await paginatedBuilder.execute() const infos = await this.views.getAccoutInfosByDid( @@ -816,6 +836,20 @@ export class ModerationService { return result ?? null } + // This is used to check if the reporter of an incoming report is muted from reporting + // so we want to make sure this look up is as fast as possible + async isReportingMutedForSubject(did: string) { + const result = await this.db.db + .selectFrom('moderation_subject_status') + .where('did', '=', did) + .where('recordPath', '=', '') + .where('muteReportingUntil', '>', new Date().toISOString()) + .select(sql`true`.as('status')) + .executeTakeFirst() + + return !!result + } + async formatAndCreateLabels( uri: string, cid: string | null, diff --git a/packages/ozone/src/mod-service/lang-data.ts b/packages/ozone/src/mod-service/lang-data.ts new file mode 100644 index 00000000000..cb41c6db60a --- /dev/null +++ b/packages/ozone/src/mod-service/lang-data.ts @@ -0,0 +1,561 @@ +// Also used in the client app https://github.com/bluesky-social/social-app/blob/main/src/locale/languages.ts +interface Language { + code3: string + code2: string + name: string +} + +export const LANGUAGES: Language[] = [ + { code3: 'aar', code2: 'aa', name: 'Afar' }, + { code3: 'abk', code2: 'ab', name: 'Abkhazian' }, + { code3: 'ace', code2: '', name: 'Achinese' }, + { code3: 'ach', code2: '', name: 'Acoli' }, + { code3: 'ada', code2: '', name: 'Adangme' }, + { code3: 'ady', code2: '', name: 'Adyghe; Adygei' }, + { code3: 'afa', code2: '', name: 'Afro-Asiatic languages' }, + { code3: 'afh', code2: '', name: 'Afrihili' }, + { code3: 'afr', code2: 'af', name: 'Afrikaans' }, + { code3: 'ain', code2: '', name: 'Ainu' }, + { code3: 'aka', code2: 'ak', name: 'Akan' }, + { code3: 'akk', code2: '', name: 'Akkadian' }, + { code3: 'alb', code2: 'sq', name: 'Albanian' }, + { code3: 'ale', code2: '', name: 'Aleut' }, + { code3: 'alg', code2: '', name: 'Algonquian languages' }, + { code3: 'alt', code2: '', name: 'Southern Altai' }, + { code3: 'amh', code2: 'am', name: 'Amharic' }, + { code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)' }, + { code3: 'anp ', code2: 'Angika', name: 'Angika' }, + { code3: 'apa', code2: '', name: 'Apache languages' }, + { code3: 'ara', code2: 'ar', name: 'Arabic' }, + { + code3: 'arc', + code2: '', + name: 'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', + }, + { code3: 'arg', code2: 'an', name: 'Aragonese' }, + { code3: 'arm', code2: 'hy', name: 'Armenian' }, + { code3: 'arn', code2: '', name: 'Mapudungun; Mapuche' }, + { code3: 'arp', code2: '', name: 'Arapaho' }, + { code3: 'art', code2: '', name: 'Artificial languages' }, + { code3: 'arw', code2: '', name: 'Arawak' }, + { code3: 'asm', code2: 'as', name: 'Assamese' }, + { code3: 'ast', code2: '', name: 'Asturian; Bable; Leonese; Asturleonese' }, + { code3: 'ath', code2: '', name: 'Athapascan languages' }, + { code3: 'aus', code2: '', name: 'Australian languages' }, + { code3: 'ava', code2: 'av', name: 'Avaric' }, + { code3: 'ave', code2: 'ae', name: 'Avestan' }, + { code3: 'awa', code2: '', name: 'Awadhi' }, + { code3: 'aym', code2: 'ay', name: 'Aymara' }, + { code3: 'aze', code2: 'az', name: 'Azerbaijani' }, + { code3: 'bad', code2: '', name: 'Banda languages' }, + { code3: 'bai', code2: '', name: 'Bamileke languages' }, + { code3: 'bak', code2: 'ba', name: 'Bashkir' }, + { code3: 'bal', code2: '', name: 'Baluchi' }, + { code3: 'bam', code2: 'bm', name: 'Bambara' }, + { code3: 'ban', code2: '', name: 'Balinese' }, + { code3: 'baq', code2: 'eu', name: 'Basque' }, + { code3: 'bas', code2: '', name: 'Basa' }, + { code3: 'bat', code2: '', name: 'Baltic languages' }, + { code3: 'bej', code2: '', name: 'Beja; Bedawiyet' }, + { code3: 'bel', code2: 'be', name: 'Belarusian' }, + { code3: 'bem', code2: '', name: 'Bemba' }, + { code3: 'ben', code2: 'bn', name: 'Bengali' }, + { code3: 'ber', code2: '', name: 'Berber languages' }, + { code3: 'bho', code2: '', name: 'Bhojpuri' }, + { code3: 'bih', code2: 'bh', name: 'Bihari languages' }, + { code3: 'bik', code2: '', name: 'Bikol' }, + { code3: 'bin', code2: '', name: 'Bini; Edo' }, + { code3: 'bis', code2: 'bi', name: 'Bislama' }, + { code3: 'bla', code2: '', name: 'Siksika' }, + { code3: 'bnt', code2: '', name: 'Bantu languages' }, + { code3: 'bod', code2: 'bo', name: 'Tibetan' }, + { code3: 'bos', code2: 'bs', name: 'Bosnian' }, + { code3: 'bra', code2: '', name: 'Braj' }, + { code3: 'bre', code2: 'br', name: 'Breton' }, + { code3: 'btk', code2: '', name: 'Batak languages' }, + { code3: 'bua', code2: '', name: 'Buriat' }, + { code3: 'bug', code2: '', name: 'Buginese' }, + { code3: 'bul', code2: 'bg', name: 'Bulgarian' }, + { code3: 'bur', code2: 'my', name: 'Burmese' }, + { code3: 'byn', code2: '', name: 'Blin; Bilin' }, + { code3: 'cad', code2: '', name: 'Caddo' }, + { code3: 'cai', code2: '', name: 'Central American Indian languages' }, + { code3: 'car', code2: '', name: 'Galibi Carib' }, + { code3: 'cat', code2: 'ca', name: 'Catalan; Valencian' }, + { code3: 'cau', code2: '', name: 'Caucasian languages' }, + { code3: 'ceb', code2: '', name: 'Cebuano' }, + { code3: 'cel', code2: '', name: 'Celtic languages' }, + { code3: 'ces', code2: 'cs', name: 'Czech' }, + { code3: 'cha', code2: 'ch', name: 'Chamorro' }, + { code3: 'chb', code2: '', name: 'Chibcha' }, + { code3: 'che', code2: 'ce', name: 'Chechen' }, + { code3: 'chg', code2: '', name: 'Chagatai' }, + { code3: 'chi', code2: 'zh', name: 'Chinese' }, + { code3: 'chk', code2: '', name: 'Chuukese' }, + { code3: 'chm', code2: '', name: 'Mari' }, + { code3: 'chn', code2: '', name: 'Chinook jargon' }, + { code3: 'cho', code2: '', name: 'Choctaw' }, + { code3: 'chp', code2: '', name: 'Chipewyan; Dene Suline' }, + { code3: 'chr', code2: '', name: 'Cherokee' }, + { + code3: 'chu', + code2: 'cu', + name: 'Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic', + }, + { code3: 'chv', code2: 'cv', name: 'Chuvash' }, + { code3: 'chy', code2: '', name: 'Cheyenne' }, + { code3: 'cmc', code2: '', name: 'Chamic languages' }, + { code3: 'cnr', code2: '', name: 'Montenegrin' }, + { code3: 'cop', code2: '', name: 'Coptic' }, + { code3: 'cor', code2: 'kw', name: 'Cornish' }, + { code3: 'cos', code2: 'co', name: 'Corsican' }, + { code3: 'cpe', code2: '', name: 'Creoles and pidgins, English based' }, + { code3: 'cpf', code2: '', name: 'Creoles and pidgins, French-based' }, + { code3: 'cpp', code2: '', name: 'Creoles and pidgins, Portuguese-based' }, + { code3: 'cre', code2: 'cr', name: 'Cree' }, + { code3: 'crh', code2: '', name: 'Crimean Tatar; Crimean Turkish' }, + { code3: 'crp', code2: '', name: 'Creoles and pidgins' }, + { code3: 'csb', code2: '', name: 'Kashubian' }, + { code3: 'cus', code2: '', name: 'Cushitic languages' }, + { code3: 'cym', code2: 'cy', name: 'Welsh' }, + { code3: 'cze', code2: 'cs', name: 'Czech' }, + { code3: 'dak', code2: '', name: 'Dakota' }, + { code3: 'dan', code2: 'da', name: 'Danish' }, + { code3: 'dar', code2: '', name: 'Dargwa' }, + { code3: 'day', code2: '', name: 'Land Dayak languages' }, + { code3: 'del', code2: '', name: 'Delaware' }, + { code3: 'den', code2: '', name: 'Slave (Athapascan)' }, + { code3: 'deu', code2: 'de', name: 'German' }, + { code3: 'dgr', code2: '', name: 'Dogrib' }, + { code3: 'din', code2: '', name: 'Dinka' }, + { code3: 'div', code2: 'dv', name: 'Divehi; Dhivehi; Maldivian' }, + { code3: 'doi', code2: '', name: 'Dogri' }, + { code3: 'dra', code2: '', name: 'Dravidian languages' }, + { code3: 'dsb', code2: '', name: 'Lower Sorbian' }, + { code3: 'dua', code2: '', name: 'Duala' }, + { code3: 'dum', code2: '', name: 'Dutch, Middle (ca.1050-1350)' }, + { code3: 'dut', code2: 'nl', name: 'Dutch; Flemish' }, + { code3: 'dyu', code2: '', name: 'Dyula' }, + { code3: 'dzo', code2: 'dz', name: 'Dzongkha' }, + { code3: 'efi', code2: '', name: 'Efik' }, + { code3: 'egy', code2: '', name: 'Egyptian (Ancient)' }, + { code3: 'eka', code2: '', name: 'Ekajuk' }, + { code3: 'ell', code2: 'el', name: 'Greek, Modern (1453-)' }, + { code3: 'elx', code2: '', name: 'Elamite' }, + { code3: 'eng', code2: 'en', name: 'English' }, + { code3: 'enm', code2: '', name: 'English, Middle (1100-1500)' }, + { code3: 'epo', code2: 'eo', name: 'Esperanto' }, + { code3: 'est', code2: 'et', name: 'Estonian' }, + { code3: 'eus', code2: 'eu', name: 'Basque' }, + { code3: 'ewe', code2: 'ee', name: 'Ewe' }, + { code3: 'ewo', code2: '', name: 'Ewondo' }, + { code3: 'fan', code2: '', name: 'Fang' }, + { code3: 'fao', code2: 'fo', name: 'Faroese' }, + { code3: 'fas', code2: 'fa', name: 'Persian' }, + { code3: 'fat', code2: '', name: 'Fanti' }, + { code3: 'fij', code2: 'fj', name: 'Fijian' }, + { code3: 'fil', code2: '', name: 'Filipino; Pilipino' }, + { code3: 'fin', code2: 'fi', name: 'Finnish' }, + { code3: 'fiu', code2: '', name: 'Finno-Ugrian languages' }, + { code3: 'fon', code2: '', name: 'Fon' }, + { code3: 'fra', code2: 'fr', name: 'French' }, + { code3: 'fre', code2: 'fr', name: 'French' }, + { code3: 'frm', code2: '', name: 'French, Middle (ca.1400-1600)' }, + { code3: 'fro', code2: '', name: 'French, Old (842-ca.1400)' }, + { code3: 'frr', code2: '', name: 'Northern Frisian' }, + { code3: 'frs', code2: '', name: 'Eastern Frisian' }, + { code3: 'fry', code2: 'fy', name: 'Western Frisian' }, + { code3: 'ful', code2: 'ff', name: 'Fulah' }, + { code3: 'fur', code2: '', name: 'Friulian' }, + { code3: 'gaa', code2: '', name: 'Ga' }, + { code3: 'gay', code2: '', name: 'Gayo' }, + { code3: 'gba', code2: '', name: 'Gbaya' }, + { code3: 'gem', code2: '', name: 'Germanic languages' }, + { code3: 'geo', code2: 'ka', name: 'Georgian' }, + { code3: 'ger', code2: 'de', name: 'German' }, + { code3: 'gez', code2: '', name: 'Geez' }, + { code3: 'gil', code2: '', name: 'Gilbertese' }, + { code3: 'gla', code2: 'gd', name: 'Gaelic; Scottish Gaelic' }, + { code3: 'gle', code2: 'ga', name: 'Irish' }, + { code3: 'glg', code2: 'gl', name: 'Galician' }, + { code3: 'glv', code2: 'gv', name: 'Manx' }, + { code3: 'gmh', code2: '', name: 'German, Middle High (ca.1050-1500)' }, + { code3: 'goh', code2: '', name: 'German, Old High (ca.750-1050)' }, + { code3: 'gon', code2: '', name: 'Gondi' }, + { code3: 'gor', code2: '', name: 'Gorontalo' }, + { code3: 'got', code2: '', name: 'Gothic' }, + { code3: 'grb', code2: '', name: 'Grebo' }, + { code3: 'grc', code2: '', name: 'Greek, Ancient (to 1453)' }, + { code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)' }, + { code3: 'grn', code2: 'gn', name: 'Guarani' }, + { code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian' }, + { code3: 'gujgu', code2: 'Gujarati', name: 'goudjrati' }, + { code3: 'gwi', code2: '', name: "Gwich'in" }, + { code3: 'hai', code2: '', name: 'Haida' }, + { code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole' }, + { code3: 'hau', code2: 'ha', name: 'Hausa' }, + { code3: 'haw', code2: '', name: 'Hawaiian' }, + { code3: 'heb', code2: 'he', name: 'Hebrew' }, + { code3: 'her', code2: 'hz', name: 'Herero' }, + { code3: 'hil', code2: '', name: 'Hiligaynon' }, + { + code3: 'him', + code2: '', + name: 'Himachali languages; Western Pahari languages', + }, + { code3: 'hin', code2: 'hi', name: 'Hindi' }, + { code3: 'hit', code2: '', name: 'Hittite' }, + { code3: 'hmn', code2: '', name: 'Hmong; Mong' }, + { code3: 'hmo', code2: 'ho', name: 'Hiri Motu' }, + { code3: 'hrv', code2: 'hr', name: 'Croatian' }, + { code3: 'hsb', code2: '', name: 'Upper Sorbian' }, + { code3: 'hun', code2: 'hu', name: 'Hungarian' }, + { code3: 'hup', code2: '', name: 'Hupa' }, + { code3: 'hye', code2: 'hy', name: 'Armenian' }, + { code3: 'iba', code2: '', name: 'Iban' }, + { code3: 'ibo', code2: 'ig', name: 'Igbo' }, + { code3: 'ice', code2: 'is', name: 'Icelandic' }, + { code3: 'ido', code2: 'io', name: 'Ido' }, + { code3: 'iii', code2: 'ii', name: 'Sichuan Yi; Nuosu' }, + { code3: 'ijo', code2: '', name: 'Ijo languages' }, + { code3: 'iku', code2: 'iu', name: 'Inuktitut' }, + { code3: 'ile', code2: 'ie', name: 'Interlingue; Occidental' }, + { code3: 'ilo', code2: '', name: 'Iloko' }, + { + code3: 'ina', + code2: 'ia', + name: 'Interlingua (International Auxiliary Language Association)', + }, + { code3: 'inc', code2: '', name: 'Indic languages' }, + { code3: 'ind', code2: 'id', name: 'Indonesian' }, + { code3: 'ine', code2: '', name: 'Indo-European languages' }, + { code3: 'inh', code2: '', name: 'Ingush' }, + { code3: 'ipk', code2: 'ik', name: 'Inupiaq' }, + { code3: 'ira', code2: '', name: 'Iranian languages' }, + { code3: 'iro', code2: '', name: 'Iroquoian languages' }, + { code3: 'isl', code2: 'is', name: 'Icelandic' }, + { code3: 'ita', code2: 'it', name: 'Italian' }, + { code3: 'jav', code2: 'jv', name: 'Javanese' }, + { code3: 'jbo', code2: '', name: 'Lojban' }, + { code3: 'jpn', code2: 'ja', name: 'Japanese' }, + { code3: 'jpr', code2: '', name: 'Judeo-Persian' }, + { code3: 'jrb', code2: '', name: 'Judeo-Arabic' }, + { code3: 'kaa', code2: '', name: 'Kara-Kalpak' }, + { code3: 'kab', code2: '', name: 'Kabyle' }, + { code3: 'kac', code2: '', name: 'Kachin; Jingpho' }, + { code3: 'kal', code2: 'kl', name: 'Kalaallisut; Greenlandic' }, + { code3: 'kam', code2: '', name: 'Kamba' }, + { code3: 'kan', code2: 'kn', name: 'Kannada' }, + { code3: 'kar', code2: '', name: 'Karen languages' }, + { code3: 'kas', code2: 'ks', name: 'Kashmiri' }, + { code3: 'kat', code2: 'ka', name: 'Georgian' }, + { code3: 'kau', code2: 'kr', name: 'Kanuri' }, + { code3: 'kaw', code2: '', name: 'Kawi' }, + { code3: 'kaz', code2: 'kk', name: 'Kazakh' }, + { code3: 'kbd', code2: '', name: 'Kabardian' }, + { code3: 'kha', code2: '', name: 'Khasi' }, + { code3: 'khi', code2: '', name: 'Khoisan languages' }, + { code3: 'khm', code2: 'km', name: 'Central Khmer' }, + { code3: 'kho', code2: '', name: 'Khotanese; Sakan' }, + { code3: 'kik', code2: 'ki', name: 'Kikuyu; Gikuyu' }, + { code3: 'kin', code2: 'rw', name: 'Kinyarwanda' }, + { code3: 'kir', code2: 'ky', name: 'Kirghiz; Kyrgyz' }, + { code3: 'kmb', code2: '', name: 'Kimbundu' }, + { code3: 'kok', code2: '', name: 'Konkani' }, + { code3: 'kom', code2: 'kv', name: 'Komi' }, + { code3: 'kon', code2: 'kg', name: 'Kongo' }, + { code3: 'kor', code2: 'ko', name: 'Korean' }, + { code3: 'kos', code2: '', name: 'Kosraean' }, + { code3: 'kpe', code2: '', name: 'Kpelle' }, + { code3: 'krc', code2: '', name: 'Karachay-Balkar' }, + { code3: 'krl', code2: '', name: 'Karelian' }, + { code3: 'kro', code2: '', name: 'Kru languages' }, + { code3: 'kru', code2: '', name: 'Kurukh' }, + { code3: 'kua', code2: 'kj', name: 'Kuanyama; Kwanyama' }, + { code3: 'kum', code2: '', name: 'Kumyk' }, + { code3: 'kur', code2: 'ku', name: 'Kurdish' }, + { code3: 'kut', code2: '', name: 'Kutenai' }, + { code3: 'lad', code2: '', name: 'Ladino' }, + { code3: 'lah', code2: '', name: 'Lahnda' }, + { code3: 'lam', code2: '', name: 'Lamba' }, + { code3: 'lao', code2: 'lo', name: 'Lao' }, + { code3: 'lat', code2: 'la', name: 'Latin' }, + { code3: 'lav', code2: 'lv', name: 'Latvian' }, + { code3: 'lez', code2: '', name: 'Lezghian' }, + { code3: 'lim', code2: 'li', name: 'Limburgan; Limburger; Limburgish' }, + { code3: 'lin', code2: 'ln', name: 'Lingala' }, + { code3: 'lit', code2: 'lt', name: 'Lithuanian' }, + { code3: 'lol', code2: '', name: 'Mongo' }, + { code3: 'loz', code2: '', name: 'Lozi' }, + { code3: 'ltz', code2: 'lb', name: 'Luxembourgish; Letzeburgesch' }, + { code3: 'lua', code2: '', name: 'Luba-Lulua' }, + { code3: 'lub', code2: 'lu', name: 'Luba-Katanga' }, + { code3: 'lug', code2: 'lg', name: 'Ganda' }, + { code3: 'lui', code2: '', name: 'Luiseno' }, + { code3: 'lun', code2: '', name: 'Lunda' }, + { + code3: 'luo', + code2: ' Luo (Kenya and Tanzania)', + name: 'luo (Kenya et Tanzanie)', + }, + { code3: 'lus', code2: '', name: 'Lushai' }, + { code3: 'mac', code2: 'mk', name: 'Macedonian' }, + { code3: 'mad', code2: '', name: 'Madurese' }, + { code3: 'mag', code2: '', name: 'Magahi' }, + { code3: 'mah', code2: 'mh', name: 'Marshallese' }, + { code3: 'mai', code2: '', name: 'Maithili' }, + { code3: 'mak', code2: '', name: 'Makasar' }, + { code3: 'mal', code2: 'ml', name: 'Malayalam' }, + { code3: 'man', code2: '', name: 'Mandingo' }, + { code3: 'mao', code2: 'mi', name: 'Maori' }, + { code3: 'map', code2: '', name: 'Austronesian languages' }, + { code3: 'mar', code2: 'mr', name: 'Marathi' }, + { code3: 'mas', code2: '', name: 'Masai' }, + { code3: 'may', code2: 'ms', name: 'Malay' }, + { code3: 'mdf', code2: '', name: 'Moksha' }, + { code3: 'mdr', code2: '', name: 'Mandar' }, + { code3: 'men', code2: '', name: 'Mende' }, + { code3: 'mga', code2: '', name: 'Irish, Middle (900-1200)' }, + { code3: 'mic', code2: '', name: "Mi'kmaq; Micmac" }, + { code3: 'min', code2: '', name: 'Minangkabau' }, + { code3: 'mis', code2: '', name: 'Uncoded languages' }, + { code3: 'mkd', code2: 'mk', name: 'Macedonian' }, + { code3: 'mkh', code2: '', name: 'Mon-Khmer languages' }, + { code3: 'mlg', code2: 'mg', name: 'Malagasy' }, + { code3: 'mlt', code2: 'mt', name: 'Maltese' }, + { code3: 'mnc', code2: '', name: 'Manchu' }, + { code3: 'mni', code2: '', name: 'Manipuri' }, + { code3: 'mno', code2: '', name: 'Manobo languages' }, + { code3: 'moh', code2: '', name: 'Mohawk' }, + { code3: 'mon', code2: 'mn', name: 'Mongolian' }, + { code3: 'mos', code2: '', name: 'Mossi' }, + { code3: 'mri', code2: 'mi', name: 'Maori' }, + { code3: 'msa', code2: 'ms', name: 'Malay' }, + { code3: 'mul', code2: '', name: 'Multiple languages' }, + { code3: 'mun', code2: '', name: 'Munda languages' }, + { code3: 'mus', code2: '', name: 'Creek' }, + { code3: 'mwl', code2: '', name: 'Mirandese' }, + { code3: 'mwr', code2: '', name: 'Marwari' }, + { code3: 'mya', code2: 'my', name: 'Burmese' }, + { code3: 'myn', code2: '', name: 'Mayan languages' }, + { code3: 'myv', code2: '', name: 'Erzya' }, + { code3: 'nah', code2: '', name: 'Nahuatl languages' }, + { code3: 'nai', code2: '', name: 'North American Indian languages' }, + { code3: 'nap', code2: '', name: 'Neapolitan' }, + { code3: 'nau', code2: 'na', name: 'Nauru' }, + { code3: 'nav', code2: 'nv', name: 'Navajo; Navaho' }, + { code3: 'nbl', code2: 'nr', name: 'Ndebele, South; South Ndebele' }, + { code3: 'nde', code2: 'nd', name: 'Ndebele, North; North Ndebele' }, + { code3: 'ndo', code2: 'ng', name: 'Ndonga' }, + { + code3: 'nds', + code2: '', + name: 'Low German; Low Saxon; German, Low; Saxon, Low', + }, + { code3: 'nep', code2: 'ne', name: 'Nepali' }, + { code3: 'new', code2: '', name: 'Nepal Bhasa; Newari' }, + { code3: 'nia', code2: '', name: 'Nias' }, + { code3: 'nic', code2: '', name: 'Niger-Kordofanian languages' }, + { code3: 'niu', code2: '', name: 'Niuean' }, + { code3: 'nld', code2: 'nl', name: 'Dutch; Flemish' }, + { code3: 'nno', code2: 'nn', name: 'Norwegian Nynorsk; Nynorsk, Norwegian' }, + { code3: 'nob', code2: 'nb', name: 'Bokmål, Norwegian; Norwegian Bokmål' }, + { code3: 'nog', code2: '', name: 'Nogai' }, + { code3: 'non', code2: '', name: 'Norse, Old' }, + { code3: 'nor', code2: 'no', name: 'Norwegian' }, + { code3: 'nqo', code2: '', name: "N'Ko" }, + { code3: 'nso', code2: '', name: 'Pedi; Sepedi; Northern Sotho' }, + { code3: 'nub', code2: '', name: 'Nubian languages' }, + { + code3: 'nwc', + code2: '', + name: 'Classical Newari; Old Newari; Classical Nepal Bhasa', + }, + { code3: 'nya', code2: 'ny', name: 'Chichewa; Chewa; Nyanja' }, + { code3: 'nym', code2: '', name: 'Nyamwezi' }, + { code3: 'nyn', code2: '', name: 'Nyankole' }, + { code3: 'nyo', code2: '', name: 'Nyoro' }, + { code3: 'nzi', code2: '', name: 'Nzima' }, + { code3: 'oci', code2: 'oc', name: 'Occitan (post 1500)' }, + { code3: 'oji', code2: 'oj', name: 'Ojibwa' }, + { code3: 'ori', code2: 'or', name: 'Oriya' }, + { code3: 'orm', code2: 'om', name: 'Oromo' }, + { code3: 'osa', code2: '', name: 'Osage' }, + { code3: 'oss', code2: 'os', name: 'Ossetian; Ossetic' }, + { code3: 'ota', code2: '', name: 'Turkish, Ottoman (1500-1928)' }, + { code3: 'oto', code2: '', name: 'Otomian languages' }, + { code3: 'paa', code2: '', name: 'Papuan languages' }, + { code3: 'pag', code2: '', name: 'Pangasinan' }, + { code3: 'pal', code2: ' ', name: 'Pahlavi' }, + { code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan' }, + { code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi' }, + { code3: 'pap', code2: ' ', name: 'Papiamento' }, + { code3: 'pau', code2: ' ', name: 'Palauan' }, + { code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)' }, + { code3: 'per', code2: 'fa', name: 'Persian' }, + { code3: 'phi', code2: ' ', name: 'Philippine languages' }, + { code3: 'phn', code2: ' ', name: 'Phoenician' }, + { code3: 'pli', code2: 'pi', name: 'Pali' }, + { code3: 'pol', code2: 'pl', name: 'Polish' }, + { code3: 'pon', code2: ' ', name: 'Pohnpeian' }, + { code3: 'por', code2: 'pt', name: 'Portuguese' }, + { code3: 'pra', code2: ' ', name: 'Prakrit languages' }, + { + code3: 'pro', + code2: ' ', + name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)', + }, + { code3: 'pus', code2: 'ps', name: 'Pushto; Pashto' }, + { code3: 'que', code2: 'qu', name: 'Quechua' }, + { code3: 'raj', code2: ' ', name: 'Rajasthani' }, + { code3: 'rap', code2: ' ', name: 'Rapanui' }, + { code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori' }, + { code3: 'roa', code2: ' ', name: 'Romance languages' }, + { code3: 'roh', code2: 'rm', name: 'Romansh' }, + { code3: 'rom', code2: ' ', name: 'Romany' }, + { code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan' }, + { code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan' }, + { code3: 'run', code2: 'rn', name: 'Rundi' }, + { code3: 'rup', code2: ' ', name: 'Aromanian; Arumanian; Macedo-Romanian' }, + { code3: 'rus', code2: 'ru', name: 'Russian' }, + { code3: 'sad', code2: ' ', name: 'Sandawe' }, + { code3: 'sag', code2: 'sg', name: 'Sango' }, + { code3: 'sah', code2: ' ', name: 'Yakut' }, + { code3: 'sai', code2: ' ', name: 'South American Indian languages' }, + { code3: 'sal', code2: ' ', name: 'Salishan languages' }, + { code3: 'sam', code2: ' ', name: 'Samaritan Aramaic' }, + { code3: 'san', code2: 'sa', name: 'Sanskrit' }, + { code3: 'sas', code2: ' ', name: 'Sasak' }, + { code3: 'sat', code2: ' ', name: 'Santali' }, + { code3: 'scn', code2: ' ', name: 'Sicilian' }, + { code3: 'sco', code2: ' ', name: 'Scots' }, + { code3: 'sel', code2: ' ', name: 'Selkup' }, + { code3: 'sem', code2: ' ', name: 'Semitic languages' }, + { code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)' }, + { code3: 'sgn', code2: ' ', name: 'Sign Languages' }, + { code3: 'shn', code2: ' ', name: 'Shan' }, + { code3: 'sid', code2: ' ', name: 'Sidamo' }, + { code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese' }, + { code3: 'sio', code2: ' ', name: 'Siouan languages' }, + { code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages' }, + { code3: 'sla', code2: ' ', name: 'Slavic languages' }, + { code3: 'slo', code2: 'sk', name: 'Slovak' }, + { code3: 'slk', code2: 'sk', name: 'Slovak' }, + { code3: 'slv', code2: 'sl', name: 'Slovenian' }, + { code3: 'sma', code2: ' ', name: 'Southern Sami' }, + { code3: 'sme', code2: 'se', name: 'Northern Sami' }, + { code3: 'smi', code2: ' ', name: 'Sami languages' }, + { code3: 'smj', code2: ' ', name: 'Lule Sami' }, + { code3: 'smn', code2: ' ', name: 'Inari Sami' }, + { code3: 'smo', code2: 'sm', name: 'Samoan' }, + { code3: 'sms', code2: ' ', name: 'Skolt Sami' }, + { code3: 'sna', code2: 'sn', name: 'Shona' }, + { code3: 'snd', code2: 'sd', name: 'Sindhi' }, + { code3: 'snk', code2: ' ', name: 'Soninke' }, + { code3: 'sog', code2: ' ', name: 'Sogdian' }, + { code3: 'som', code2: 'so', name: 'Somali' }, + { code3: 'son', code2: ' ', name: 'Songhai languages' }, + { code3: 'sot', code2: 'st', name: 'Sotho, Southern' }, + { code3: 'spa', code2: 'es', name: 'Spanish' }, + { code3: 'sqi', code2: 'sq', name: 'Albanian' }, + { code3: 'srd', code2: 'sc', name: 'Sardinian' }, + { code3: 'srn', code2: ' ', name: 'Sranan Tongo' }, + { code3: 'srp', code2: 'sr', name: 'Serbian' }, + { code3: 'srr', code2: ' ', name: 'Serer' }, + { code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages' }, + { code3: 'ssw', code2: 'ss', name: 'Swati' }, + { code3: 'suk', code2: ' ', name: 'Sukuma' }, + { code3: 'sun', code2: 'su', name: 'Sundanese' }, + { code3: 'sus', code2: ' ', name: 'Susu' }, + { code3: 'sux', code2: ' ', name: 'Sumerian' }, + { code3: 'swa', code2: 'sw', name: 'Swahili' }, + { code3: 'swe', code2: 'sv', name: 'Swedish' }, + { code3: 'syc', code2: ' ', name: 'Classical Syriac' }, + { code3: 'syr', code2: ' ', name: 'Syriac' }, + { code3: 'tah', code2: 'ty', name: 'Tahitian' }, + { code3: 'tai', code2: ' ', name: 'Tai languages' }, + { code3: 'tam', code2: 'ta', name: 'Tamil' }, + { code3: 'tat', code2: 'tt', name: 'Tatar' }, + { code3: 'tel', code2: 'te', name: 'Telugu' }, + { code3: 'tem', code2: ' ', name: 'Timne' }, + { code3: 'ter', code2: ' ', name: 'Tereno' }, + { code3: 'tet', code2: ' ', name: 'Tetum' }, + { code3: 'tgk', code2: 'tg', name: 'Tajik' }, + { code3: 'tgl', code2: 'tl', name: 'Tagalog' }, + { code3: 'tha', code2: 'th', name: 'Thai' }, + { code3: 'tib', code2: 'bo', name: 'Tibetan' }, + { code3: 'tig', code2: ' ', name: 'Tigre' }, + { code3: 'tir', code2: 'ti', name: 'Tigrinya' }, + { code3: 'tiv', code2: ' ', name: 'Tiv' }, + { code3: 'tkl', code2: ' ', name: 'Tokelau' }, + { code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol' }, + { code3: 'tli', code2: ' ', name: 'Tlingit' }, + { code3: 'tmh', code2: ' ', name: 'Tamashek' }, + { code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)' }, + { code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)' }, + { code3: 'tpi', code2: ' ', name: 'Tok Pisin' }, + { code3: 'tsi', code2: ' ', name: 'Tsimshian' }, + { code3: 'tsn', code2: 'tn', name: 'Tswana' }, + { code3: 'tso', code2: 'ts', name: 'Tsonga' }, + { code3: 'tuk', code2: 'tk', name: 'Turkmen' }, + { code3: 'tum', code2: ' ', name: 'Tumbuka' }, + { code3: 'tup', code2: ' ', name: 'Tupi languages' }, + { code3: 'tur', code2: 'tr', name: 'Turkish' }, + { code3: 'tut', code2: ' ', name: 'Altaic languages' }, + { code3: 'tvl', code2: ' ', name: 'Tuvalu' }, + { code3: 'twi', code2: 'tw', name: 'Twi' }, + { code3: 'tyv', code2: ' ', name: 'Tuvinian' }, + { code3: 'udm', code2: ' ', name: 'Udmurt' }, + { code3: 'uga', code2: ' ', name: 'Ugaritic' }, + { code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur' }, + { code3: 'ukr', code2: 'uk', name: 'Ukrainian' }, + { code3: 'umb', code2: ' ', name: 'Umbundu' }, + { code3: 'und', code2: ' ', name: 'Undetermined' }, + { code3: 'urd', code2: 'ur', name: 'Urdu' }, + { code3: 'uzb', code2: 'uz', name: 'Uzbek' }, + { code3: 'vai', code2: ' ', name: 'Vai' }, + { code3: 'ven', code2: 've', name: 'Venda' }, + { code3: 'vie', code2: 'vi', name: 'Vietnamese' }, + { code3: 'vol', code2: 'vo', name: 'Volapük' }, + { code3: 'vot', code2: ' ', name: 'Votic' }, + { code3: 'wak', code2: ' ', name: 'Wakashan languages' }, + { code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta' }, + { code3: 'war', code2: ' ', name: 'Waray' }, + { code3: 'was', code2: ' ', name: 'Washo' }, + { code3: 'wel', code2: 'cy', name: 'Welsh' }, + { code3: 'wen', code2: ' ', name: 'Sorbian languages' }, + { code3: 'wln', code2: 'wa', name: 'Walloon' }, + { code3: 'wol', code2: 'wo', name: 'Wolof' }, + { code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat' }, + { code3: 'xho', code2: 'xh', name: 'Xhosa' }, + { code3: 'yao', code2: ' ', name: 'Yao' }, + { code3: 'yap', code2: ' ', name: 'Yapese' }, + { code3: 'yid', code2: 'yi', name: 'Yiddish' }, + { code3: 'yor', code2: 'yo', name: 'Yoruba' }, + { code3: 'ypk', code2: ' ', name: 'Yupik languages' }, + { code3: 'zap', code2: ' ', name: 'Zapotec' }, + { code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss' }, + { code3: 'zen', code2: ' ', name: 'Zenaga' }, + { code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight' }, + { code3: 'zha', code2: 'za', name: 'Zhuang; Chuang' }, + { code3: 'zho', code2: 'zh', name: 'Chinese' }, + { code3: 'znd', code2: ' ', name: 'Zande languages' }, + { code3: 'zul', code2: 'zu', name: 'Zulu' }, + { code3: 'zun', code2: ' ', name: 'Zuni' }, + { + code3: 'zza', + code2: '', + name: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', + }, +] + +export const LANGUAGES_MAP_CODE3 = Object.fromEntries( + LANGUAGES.map((lang) => [lang.code3, lang]), +) + +export function code3ToCode2(lang: string): string { + if (lang.length === 3) { + return LANGUAGES_MAP_CODE3[lang]?.code2 || lang + } + return lang +} diff --git a/packages/ozone/src/mod-service/lang.ts b/packages/ozone/src/mod-service/lang.ts index 02092d14743..6c17f11ad1e 100644 --- a/packages/ozone/src/mod-service/lang.ts +++ b/packages/ozone/src/mod-service/lang.ts @@ -1,7 +1,15 @@ +import { + AppBskyActorProfile, + AppBskyFeedGenerator, + AppBskyFeedPost, + AppBskyGraphList, +} from '@atproto/api' + import { ModerationService } from '.' import { ModSubject } from './subject' import { ModerationSubjectStatusRow } from './types' import { langLogger as log } from '../logger' +import { code3ToCode2 } from './lang-data' export class ModerationLangService { constructor(private moderationService: ModerationService) {} @@ -40,6 +48,23 @@ export class ModerationLangService { } } + getTextFromRecord(recordValue?: Record): string | undefined { + let text: string | undefined + + if (AppBskyGraphList.isRecord(recordValue)) { + text = recordValue.description || recordValue.name + } else if ( + AppBskyFeedGenerator.isRecord(recordValue) || + AppBskyActorProfile.isRecord(recordValue) + ) { + text = recordValue.description || recordValue.displayName + } else if (AppBskyFeedPost.isRecord(recordValue)) { + text = recordValue.text + } + + return text?.trim() + } + async getRecordLang({ subject, }: { @@ -70,10 +95,19 @@ export class ModerationLangService { ]) const record = recordByUri.get(subject.uri) const recordLang = record?.value.langs as string[] | null + const recordText = this.getTextFromRecord(record?.value) if (recordLang?.length) { recordLang .map((lang) => lang.split('-')[0]) .forEach((lang) => langs.add(lang)) + } else if (recordText) { + // 'lande' is an esm module, so we need to import it dynamically + const { default: lande } = await import('lande') + const detectedLanguages = lande(recordText) + if (detectedLanguages.length) { + const langCode = code3ToCode2(detectedLanguages[0][0]) + if (langCode) langs.add(langCode) + } } } diff --git a/packages/ozone/src/mod-service/status.ts b/packages/ozone/src/mod-service/status.ts index 80df140bfe4..939d8d3bea5 100644 --- a/packages/ozone/src/mod-service/status.ts +++ b/packages/ozone/src/mod-service/status.ts @@ -57,6 +57,15 @@ const getSubjectStatusForModerationEvent = ({ suspendUntil: null, lastReviewedAt: createdAt, } + case 'tools.ozone.moderation.defs#modEventUnmuteReporter': + return { + lastReviewedBy: createdBy, + muteReportingUntil: null, + // It's not likely to receive an unmute event that does not already have a status row + // but if it does happen, default to unnecessary + reviewState: defaultReviewState, + lastReviewedAt: createdAt, + } case 'tools.ozone.moderation.defs#modEventUnmute': return { lastReviewedBy: createdBy, @@ -76,6 +85,18 @@ const getSubjectStatusForModerationEvent = ({ ? new Date(Date.now() + durationInHours * HOUR).toISOString() : null, } + case 'tools.ozone.moderation.defs#modEventMuteReporter': + return { + lastReviewedBy: createdBy, + lastReviewedAt: createdAt, + // By default, mute for 24hrs + muteReportingUntil: new Date( + Date.now() + (durationInHours || 24) * HOUR, + ).toISOString(), + // It's not likely to receive a mute event on a subject that does not already have a status row + // but if it does happen, default to unnecessary + reviewState: defaultReviewState, + } case 'tools.ozone.moderation.defs#modEventMute': return { lastReviewedBy: createdBy, @@ -140,6 +161,11 @@ export const adjustModerationSubjectStatus = async ( .selectAll() .executeTakeFirst() + // If reporting is muted for this reporter, we don't want to update the subject status + if (meta?.isReporterMuted) { + return currentStatus || null + } + const isAppealEvent = action === 'tools.ozone.moderation.defs#modEventReport' && meta?.reportType === REASONAPPEAL diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index 64432fc1c96..4a3de9cb0d8 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -108,6 +108,7 @@ export class ModerationViews { if ( [ + 'tools.ozone.moderation.defs#modEventMuteReporter', 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventMute', ].includes(event.action) @@ -157,6 +158,7 @@ export class ModerationViews { eventView.event = { ...eventView.event, reportType: event.meta?.reportType ?? undefined, + isReporterMuted: !!event.meta?.isReporterMuted, } } @@ -500,6 +502,7 @@ export class ModerationViews { lastReportedAt: status.lastReportedAt ?? undefined, lastAppealedAt: status.lastAppealedAt ?? undefined, muteUntil: status.muteUntil ?? undefined, + muteReportingUntil: status.muteReportingUntil ?? undefined, suspendUntil: status.suspendUntil ?? undefined, takendown: status.takendown ?? undefined, appealed: status.appealed ?? undefined, diff --git a/packages/ozone/tests/__snapshots__/get-record.test.ts.snap b/packages/ozone/tests/__snapshots__/get-record.test.ts.snap index 117b3a56342..07dcb03eafb 100644 --- a/packages/ozone/tests/__snapshots__/get-record.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/get-record.test.ts.snap @@ -42,7 +42,7 @@ Object { "subjectBlobCids": Array [], "subjectRepoHandle": "alice.test", "tags": Array [ - "lang:und", + "lang:en", ], "takendown": true, "updatedAt": "1970-01-01T00:00:00.000Z", @@ -141,7 +141,7 @@ Object { "subjectBlobCids": Array [], "subjectRepoHandle": "alice.test", "tags": Array [ - "lang:und", + "lang:en", ], "takendown": true, "updatedAt": "1970-01-01T00:00:00.000Z", diff --git a/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap b/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap index 3cfb3ebbd11..db3f1a75ecd 100644 --- a/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/moderation-events.test.ts.snap @@ -7,6 +7,7 @@ Object { "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "X", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonMisleading", }, "id": 1, @@ -77,6 +78,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "X", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonSpam", }, "id": 11, @@ -113,6 +115,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "X", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonSpam", }, "id": 5, @@ -135,6 +138,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "X", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonSpam", }, "id": 10, @@ -152,7 +156,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventTag", "add": Array [ - "lang:und", + "lang:en", ], "remove": Array [], }, @@ -172,6 +176,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "X", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonSpam", }, "id": 3, diff --git a/packages/ozone/tests/__snapshots__/moderation-statuses.test.ts.snap b/packages/ozone/tests/__snapshots__/moderation-statuses.test.ts.snap index 6e96c9fd89c..96c288ffa09 100644 --- a/packages/ozone/tests/__snapshots__/moderation-statuses.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/moderation-statuses.test.ts.snap @@ -94,7 +94,7 @@ Array [ "subjectBlobCids": Array [], "subjectRepoHandle": "alice.test", "tags": Array [ - "lang:und", + "lang:ha", ], "takendown": false, "updatedAt": "1970-01-01T00:00:00.000Z", diff --git a/packages/ozone/tests/communication-templates.test.ts b/packages/ozone/tests/communication-templates.test.ts index 12604906e0a..fc9e8e3d006 100644 --- a/packages/ozone/tests/communication-templates.test.ts +++ b/packages/ozone/tests/communication-templates.test.ts @@ -42,11 +42,11 @@ describe('communication-templates', () => { { ...templateOne, createdBy: sc.dids.bob }, { encoding: 'application/json', - headers: await network.ozone.modHeaders('moderator'), + headers: await network.ozone.modHeaders('triage'), }, ) await expect(moderatorReq).rejects.toThrow( - 'Must be an admin to create a communication template', + 'Must be a moderator to create a communication template', ) const modReq = await agent.api.tools.ozone.communication.createTemplate( { ...templateOne, createdBy: sc.dids.bob }, @@ -105,19 +105,19 @@ describe('communication-templates', () => { { id: '1' }, { encoding: 'application/json', - headers: await network.ozone.modHeaders('moderator'), + headers: await network.ozone.modHeaders('triage'), }, ) await expect(modReq).rejects.toThrow( - 'Must be an admin to delete a communication template', + 'Must be a moderator to delete a communication template', ) await agent.api.tools.ozone.communication.deleteTemplate( { id: '1' }, { encoding: 'application/json', - headers: await network.ozone.modHeaders('admin'), + headers: await network.ozone.modHeaders('moderator'), }, ) const list = await listTemplates() diff --git a/packages/ozone/tests/lang.test.ts b/packages/ozone/tests/lang.test.ts new file mode 100644 index 00000000000..e2e79d86792 --- /dev/null +++ b/packages/ozone/tests/lang.test.ts @@ -0,0 +1,109 @@ +import { + ModeratorClient, + SeedClient, + TestNetwork, + basicSeed, +} from '@atproto/dev-env' +import AtpAgent from '@atproto/api' +import { REASONSPAM } from '../src/lexicon/types/com/atproto/moderation/defs' + +describe('moderation status language tagging', () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + let modClient: ModeratorClient + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'ozone_blob_divert_test', + ozone: { + blobDivertUrl: `https://blob-report.com`, + blobDivertAdminPassword: 'test-auth-token', + }, + }) + agent = network.pds.getClient() + sc = network.getSeedClient() + modClient = network.ozone.getModClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + const getStatus = async (subject: string) => { + const { subjectStatuses } = await modClient.queryStatuses({ + subject, + }) + + return subjectStatuses[0] + } + + it('Adds language tag to post from text', async () => { + const createPostAndReport = async (text: string) => { + const post = await sc.post(sc.dids.carol, text) + await network.processAll() + const report = await sc.createReport({ + reasonType: REASONSPAM, + subject: { + $type: 'com.atproto.repo.strongRef', + uri: post.ref.uriStr, + cid: post.ref.cidStr, + }, + reportedBy: sc.dids.alice, + }) + + return { post, report } + } + const [japanesePost, greekPost] = await Promise.all([ + createPostAndReport('Xで有名な人達+反AIや絵描きによくない'), + createPostAndReport( + 'Λορεμ ιπσθμ δολορ σιτ αμετ, μει θτ vιδιτ νοστρθμ προπριαε', + ), + ]) + + const [japanesePostStatus, greekPostStatus] = await Promise.all([ + getStatus(japanesePost.post.ref.uriStr), + getStatus(greekPost.post.ref.uriStr), + ]) + + expect(japanesePostStatus.tags).toContain('lang:ja') + expect(greekPostStatus.tags).toContain('lang:el') + }) + + it('Uses name/description text for language tag for list', async () => { + const createListAndReport = async (name: string, description?: string) => { + const list = await sc.createList(sc.dids.carol, name, 'mod', { + description, + }) + await network.processAll() + const report = await sc.createReport({ + reasonType: REASONSPAM, + subject: { + $type: 'com.atproto.repo.strongRef', + uri: list.uriStr, + cid: list.cidStr, + }, + reportedBy: sc.dids.alice, + }) + return { list, report } + } + + const [listWithDescription, listWithoutDescription] = await Promise.all([ + createListAndReport( + 'よくない', + 'Xで有名な人達+反AIや絵描きによくない感情を持つ人達+絵描き詐称', + ), + createListAndReport('人達+反AIや絵描きによくない感情'), + ]) + + const [japaneseListStatus, chineseListStatus] = await Promise.all([ + getStatus(listWithDescription.list.uriStr), + getStatus(listWithoutDescription.list.uriStr), + ]) + + expect(japaneseListStatus.tags).toContain('lang:ja') + expect(chineseListStatus.tags).toContain('lang:ja') + }) +}) diff --git a/packages/ozone/tests/moderation-statuses.test.ts b/packages/ozone/tests/moderation-statuses.test.ts index ddc1e32aec2..f85de7f2828 100644 --- a/packages/ozone/tests/moderation-statuses.test.ts +++ b/packages/ozone/tests/moderation-statuses.test.ts @@ -99,6 +99,33 @@ describe('moderation-statuses', () => { expect(nonKlingonQueue.subjectStatuses.map((s) => s.id)).not.toContain( klingonQueue.subjectStatuses[0].id, ) + + // Verify multi lang tag exclusion + Promise.all( + nonKlingonQueue.subjectStatuses.map((s, i) => { + return modClient.emitEvent({ + subject: s.subject, + event: { + $type: 'tools.ozone.moderation.defs#modEventTag', + add: [i % 2 ? 'lang:jp' : 'lang:it'], + remove: [], + comment: 'Adding custom lang tag', + }, + createdBy: sc.dids.alice, + }) + }), + ) + + const queueWithoutKlingonAndItalian = await modClient.queryStatuses({ + excludeTags: ['lang:i', 'lang:it'], + }) + + queueWithoutKlingonAndItalian.subjectStatuses + .map((s) => s.tags) + .flat() + .forEach((tag) => { + expect(['lang:it', 'lang:i']).not.toContain(tag) + }) }) it('returns paginated statuses', async () => { diff --git a/packages/ozone/tests/repo-search.test.ts b/packages/ozone/tests/repo-search.test.ts index 717dc2a389e..93fb0577bb3 100644 --- a/packages/ozone/tests/repo-search.test.ts +++ b/packages/ozone/tests/repo-search.test.ts @@ -52,7 +52,7 @@ describe('admin repo search view', () => { const shouldContain = [ 'cara-wiegand69.test', // Present despite repo takedown 'carlos6.test', - 'carolina-mcdermott77.test', + 'carolina-mcderm77.test', ] shouldContain.forEach((handle) => expect(handles).toContain(handle)) diff --git a/packages/ozone/tests/report-muting.test.ts b/packages/ozone/tests/report-muting.test.ts new file mode 100644 index 00000000000..5a2189ba2df --- /dev/null +++ b/packages/ozone/tests/report-muting.test.ts @@ -0,0 +1,100 @@ +import { + TestNetwork, + SeedClient, + basicSeed, + ModeratorClient, +} from '@atproto/dev-env' +import { + ComAtprotoModerationDefs, + ToolsOzoneModerationDefs, +} from '@atproto/api' +import { + REVIEWNONE, + REVIEWOPEN, +} from '../src/lexicon/types/tools/ozone/moderation/defs' + +describe('report-muting', () => { + let network: TestNetwork + let sc: SeedClient + let modClient: ModeratorClient + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'ozone_report_muting', + }) + sc = network.getSeedClient() + modClient = network.ozone.getModClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + const assertSubjectStatus = async ( + subject: string, + status?: string, + ): Promise => { + const res = await modClient.queryStatuses({ + subject, + }) + expect(res.subjectStatuses[0]?.reviewState).toEqual(status) + return res.subjectStatuses[0] + } + + it('does not change reviewState when muted reporter reports', async () => { + const bobsPostSubject = { + $type: 'com.atproto.repo.strongRef', + uri: sc.posts[sc.dids.bob][1].ref.uriStr, + cid: sc.posts[sc.dids.bob][1].ref.cidStr, + } + const carolsAccountSubject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.carol, + } + + await modClient.emitEvent({ + event: { + $type: 'tools.ozone.moderation.defs#modEventMuteReporter', + durationInHours: 24, + }, + subject: carolsAccountSubject, + }) + await sc.createReport({ + reportedBy: sc.dids.carol, + reasonType: ComAtprotoModerationDefs.REASONMISLEADING, + reason: 'misleading', + subject: bobsPostSubject, + }) + + // Verify that a subject status was not created for bob's post since the reporter was muted + await assertSubjectStatus(bobsPostSubject.uri, undefined) + // Verify, however, that the event was logged + await modClient.queryEvents({ + subject: bobsPostSubject.uri, + }) + + // Verify that reporting mute duration is stored for the reporter + const carolsStatus = await assertSubjectStatus(sc.dids.carol, REVIEWNONE) + expect( + new Date(`${carolsStatus?.muteReportingUntil}`).getTime(), + ).toBeGreaterThan(Date.now()) + + await modClient.emitEvent({ + event: { + $type: 'tools.ozone.moderation.defs#modEventUnmuteReporter', + }, + subject: carolsAccountSubject, + }) + await sc.createReport({ + reportedBy: sc.dids.carol, + reasonType: ComAtprotoModerationDefs.REASONMISLEADING, + reason: 'misleading', + subject: bobsPostSubject, + }) + + // Verify that a subject status was created for bob's post since the reporter was no longer muted + await assertSubjectStatus(bobsPostSubject.uri, REVIEWOPEN) + }) +}) diff --git a/packages/pds/CHANGELOG.md b/packages/pds/CHANGELOG.md index 6cd34be8a4c..b0ee3347094 100644 --- a/packages/pds/CHANGELOG.md +++ b/packages/pds/CHANGELOG.md @@ -1,5 +1,44 @@ # @atproto/pds +## 0.4.18 + +### Patch Changes + +- [#2390](https://github.com/bluesky-social/atproto/pull/2390) [`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933) Thanks [@foysalit](https://github.com/foysalit)! - Allow muting reports from accounts via `#modEventMuteReporter` event + +- Updated dependencies [[`58551bbe0`](https://github.com/bluesky-social/atproto/commit/58551bbe0595462c44fc3b6ab5b83e520f141933)]: + - @atproto/api@0.12.7 + +## 0.4.17 + +### Patch Changes + +- Updated dependencies [[`b9b7c5821`](https://github.com/bluesky-social/atproto/commit/b9b7c582199d57d2fe0af8af5c8c411ed34f5b9d)]: + - @atproto/api@0.12.6 + +## 0.4.16 + +### Patch Changes + +- Updated dependencies [[`3424a1770`](https://github.com/bluesky-social/atproto/commit/3424a17703891f5678ec76ef97e696afb3288b22)]: + - @atproto/api@0.12.5 + +## 0.4.15 + +### Patch Changes + +- [#2416](https://github.com/bluesky-social/atproto/pull/2416) [`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05) Thanks [@devinivy](https://github.com/devinivy)! - Support for email auth factor lexicons + +- Updated dependencies [[`93a4a4df9`](https://github.com/bluesky-social/atproto/commit/93a4a4df9ce38f89a5d05e300d247b85fb007e05)]: + - @atproto/api@0.12.4 + +## 0.4.14 + +### Patch Changes + +- Updated dependencies [[`0edef0ec0`](https://github.com/bluesky-social/atproto/commit/0edef0ec01403fd6097a4d2875b68313f2f1261f), [`c6d758b8b`](https://github.com/bluesky-social/atproto/commit/c6d758b8b63f4ef50b2ab9afc62164e92a53e7f0)]: + - @atproto/api@0.12.3 + ## 0.4.13 ### Patch Changes diff --git a/packages/pds/package.json b/packages/pds/package.json index 1b0c1ab0b1a..88a7643a230 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.4.13", + "version": "0.4.18", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index fadcac2e9fc..3eaac00d33b 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -9,7 +9,9 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.access, handler: async ({ req, auth }) => { const requester = auth.credentials.did - return pipethrough(ctx, req, requester) + return pipethrough(ctx, req, requester, undefined, { + reqHeadersToForward: ['x-bsky-topics'], + }) }, }) } diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 601dbba8eed..d9fd008fcb7 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -16,7 +16,9 @@ export default function (server: Server, ctx: AppContext) { { feed: params.feed }, await ctx.appviewAuthHeaders(requester), ) - return pipethrough(ctx, req, requester, feed.view.did) + return pipethrough(ctx, req, requester, feed.view.did, { + reqHeadersToForward: ['x-bsky-topics'], + }) }, }) } diff --git a/packages/pds/src/api/app/bsky/feed/index.ts b/packages/pds/src/api/app/bsky/feed/index.ts index 026ce86f612..e143c003f99 100644 --- a/packages/pds/src/api/app/bsky/feed/index.ts +++ b/packages/pds/src/api/app/bsky/feed/index.ts @@ -14,6 +14,7 @@ import getRepostedBy from './getRepostedBy' import getSuggestedFeeds from './getSuggestedFeeds' import getTimeline from './getTimeline' import searchPosts from './searchPosts' +import sendInteractions from './sendInteractions' export default function (server: Server, ctx: AppContext) { getActorFeeds(server, ctx) @@ -30,4 +31,5 @@ export default function (server: Server, ctx: AppContext) { getSuggestedFeeds(server, ctx) getTimeline(server, ctx) searchPosts(server, ctx) + sendInteractions(server, ctx) } diff --git a/packages/pds/src/api/app/bsky/feed/sendInteractions.ts b/packages/pds/src/api/app/bsky/feed/sendInteractions.ts new file mode 100644 index 00000000000..fec297fad0c --- /dev/null +++ b/packages/pds/src/api/app/bsky/feed/sendInteractions.ts @@ -0,0 +1,13 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { pipethroughProcedure } from '../../../../pipethrough' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.feed.sendInteractions({ + auth: ctx.authVerifier.access, + handler: async ({ input, auth, req }) => { + const requester = auth.credentials.did + return pipethroughProcedure(ctx, req, input.body, requester) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index 494a9fe0196..94b717381ca 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -1,6 +1,8 @@ +import { DAY } from '@atproto/common' +import { UpstreamTimeoutError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { DAY } from '@atproto/common' +import { BlobMetadata } from '../../../../actor-store/blob/transactor' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ @@ -15,10 +17,20 @@ export default function (server: Server, ctx: AppContext) { const blob = await ctx.actorStore.writeNoTransaction( requester, async (store) => { - const metadata = await store.repo.blob.uploadBlobAndGetMetadata( - input.encoding, - input.body, - ) + let metadata: BlobMetadata + try { + metadata = await store.repo.blob.uploadBlobAndGetMetadata( + input.encoding, + input.body, + ) + } catch (err) { + if (err?.['name'] === 'AbortError') { + throw new UpstreamTimeoutError( + 'Upload timed out, please try again.', + ) + } + throw err + } return store.transact(async (actorTxn) => { const blobRef = diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 355b02cab7d..fb6efbebbc6 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -55,6 +55,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { blobstoreCfg = { provider: 's3', bucket: env.blobstoreS3Bucket, + uploadTimeoutMs: env.blobstoreS3UploadTimeoutMs || 20000, region: env.blobstoreS3Region, endpoint: env.blobstoreS3Endpoint, forcePathStyle: env.blobstoreS3ForcePathStyle, @@ -305,6 +306,7 @@ export type S3BlobstoreConfig = { region?: string endpoint?: string forcePathStyle?: boolean + uploadTimeoutMs?: number credentials?: { accessKeyId: string secretAccessKey: string diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index f7b9849a1a9..e94c84baeb9 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -33,6 +33,7 @@ export const readEnv = (): ServerEnvironment => { blobstoreS3ForcePathStyle: envBool('PDS_BLOBSTORE_S3_FORCE_PATH_STYLE'), blobstoreS3AccessKeyId: envStr('PDS_BLOBSTORE_S3_ACCESS_KEY_ID'), blobstoreS3SecretAccessKey: envStr('PDS_BLOBSTORE_S3_SECRET_ACCESS_KEY'), + blobstoreS3UploadTimeoutMs: envInt('PDS_BLOBSTORE_S3_UPLOAD_TIMEOUT_MS'), // disk blobstoreDiskLocation: envStr('PDS_BLOBSTORE_DISK_LOCATION'), blobstoreDiskTmpLocation: envStr('PDS_BLOBSTORE_DISK_TMP_LOCATION'), @@ -143,6 +144,7 @@ export type ServerEnvironment = { blobstoreS3ForcePathStyle?: boolean blobstoreS3AccessKeyId?: string blobstoreS3SecretAccessKey?: string + blobstoreS3UploadTimeoutMs?: number // identity didPlcUrl?: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 7da2ac980ea..021fbe2e39a 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -107,6 +107,7 @@ export class AppContext { endpoint: cfg.blobstore.endpoint, forcePathStyle: cfg.blobstore.forcePathStyle, credentials: cfg.blobstore.credentials, + uploadTimeoutMs: cfg.blobstore.uploadTimeoutMs, }) : DiskBlobStore.creator( cfg.blobstore.location, diff --git a/packages/pds/src/db/tables/moderation.ts b/packages/pds/src/db/tables/moderation.ts index 1dddac1ee3d..e8707d4cff4 100644 --- a/packages/pds/src/db/tables/moderation.ts +++ b/packages/pds/src/db/tables/moderation.ts @@ -23,7 +23,13 @@ export interface ModerationAction { | 'tools.ozone.moderation.defs#modEventLabel' | 'tools.ozone.moderation.defs#modEventReport' | 'tools.ozone.moderation.defs#modEventMute' + | 'tools.ozone.moderation.defs#modEventUnmute' + | 'tools.ozone.moderation.defs#modEventMuteReporter' + | 'tools.ozone.moderation.defs#modEventUnmuteReporter' | 'tools.ozone.moderation.defs#modEventReverseTakedown' + | 'tools.ozone.moderation.defs#modEventEmail' + | 'tools.ozone.moderation.defs#modEventResolveAppeal' + | 'tools.ozone.moderation.defs#modEventDivert' subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef' subjectDid: string subjectUri: string | null diff --git a/packages/pds/src/handle/index.ts b/packages/pds/src/handle/index.ts index ec531fcc80f..7f9859c8715 100644 --- a/packages/pds/src/handle/index.ts +++ b/packages/pds/src/handle/index.ts @@ -86,7 +86,7 @@ export const ensureHandleServiceConstraints = ( if (front.length < 3) { throw new InvalidRequestError('Handle too short', 'InvalidHandle') } - if (handle.length > 30) { + if (front.length > 18) { throw new InvalidRequestError('Handle too long', 'InvalidHandle') } if (!allowReserved && reservedSubdomains[front]) { diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index ea7d29b940d..435a3d35e52 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -125,6 +125,7 @@ import * as AppBskyNotificationListNotifications from './types/app/bsky/notifica import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/registerPush' import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton' 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' @@ -1683,6 +1684,17 @@ export class AppBskyUnspeccedNS { return this._server.xrpc.method(nsid, cfg) } + getSuggestionsSkeleton( + cfg: ConfigOf< + AV, + AppBskyUnspeccedGetSuggestionsSkeleton.Handler>, + AppBskyUnspeccedGetSuggestionsSkeleton.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.unspecced.getSuggestionsSkeleton' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getTaggedSuggestions( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 72098b69572..c48bed50296 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2209,6 +2209,9 @@ export const schemaDict = { password: { type: 'string', }, + authFactorToken: { + type: 'string', + }, }, }, }, @@ -2241,6 +2244,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, }, }, }, @@ -2248,6 +2254,9 @@ export const schemaDict = { { name: 'AccountTakedown', }, + { + name: 'AuthFactorTokenRequired', + }, ], }, }, @@ -2568,6 +2577,9 @@ export const schemaDict = { emailConfirmed: { type: 'boolean', }, + emailAuthFactor: { + type: 'boolean', + }, didDoc: { type: 'unknown', }, @@ -2837,6 +2849,9 @@ export const schemaDict = { email: { type: 'string', }, + emailAuthFactor: { + type: 'boolean', + }, token: { type: 'string', description: @@ -3807,6 +3822,7 @@ export const schemaDict = { 'lex:app.bsky.actor.defs#adultContentPref', 'lex:app.bsky.actor.defs#contentLabelPref', 'lex:app.bsky.actor.defs#savedFeedsPref', + 'lex:app.bsky.actor.defs#savedFeedsPrefV2', 'lex:app.bsky.actor.defs#personalDetailsPref', 'lex:app.bsky.actor.defs#feedViewPref', 'lex:app.bsky.actor.defs#threadViewPref', @@ -3845,6 +3861,38 @@ export const schemaDict = { }, }, }, + savedFeed: { + type: 'object', + required: ['id', 'type', 'value', 'pinned'], + properties: { + id: { + type: 'string', + }, + type: { + type: 'string', + knownValues: ['feed', 'list', 'timeline'], + }, + value: { + type: 'string', + }, + pinned: { + type: 'boolean', + }, + }, + }, + savedFeedsPrefV2: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#savedFeed', + }, + }, + }, + }, savedFeedsPref: { type: 'object', required: ['pinned', 'saved'], @@ -4307,12 +4355,6 @@ export const schemaDict = { type: 'string', description: 'Search query prefix; not a full query string.', }, - viewer: { - type: 'string', - format: 'did', - description: - 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', - }, limit: { type: 'integer', minimum: 1, @@ -7849,6 +7891,56 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetSuggestionsSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestionsSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested actors. Intended to be called and then hydrated through app.bsky.actor.getSuggestions', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + cursor: { + type: 'string', + }, + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.defs#skeletonSearchActor', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyUnspeccedGetTaggedSuggestions: { lexicon: 1, id: 'app.bsky.unspecced.getTaggedSuggestions', @@ -8316,6 +8408,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8375,6 +8470,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventAcknowledge', 'lex:tools.ozone.moderation.defs#modEventEscalate', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventEmail', 'lex:tools.ozone.moderation.defs#modEventResolveAppeal', 'lex:tools.ozone.moderation.defs#modEventDivert', @@ -8454,6 +8552,10 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + muteReportingUntil: { + type: 'string', + format: 'datetime', + }, lastReviewedBy: { type: 'string', format: 'did', @@ -8577,6 +8679,11 @@ export const schemaDict = { comment: { type: 'string', }, + isReporterMuted: { + type: 'boolean', + description: + "Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.", + }, reportType: { type: 'ref', ref: 'lex:com.atproto.moderation.defs#reasonType', @@ -8645,6 +8752,30 @@ export const schemaDict = { }, }, }, + modEventMuteReporter: { + type: 'object', + description: 'Mute incoming reports from an account', + required: ['durationInHours'], + properties: { + comment: { + type: 'string', + }, + durationInHours: { + type: 'integer', + description: 'Indicates how long the account should remain muted.', + }, + }, + }, + modEventUnmuteReporter: { + type: 'object', + description: 'Unmute incoming reports from an account', + properties: { + comment: { + type: 'string', + description: 'Describe reasoning behind the reversal.', + }, + }, + }, modEventEmail: { type: 'object', description: 'Keep a log of outgoing email to a user', @@ -9029,6 +9160,9 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventLabel', 'lex:tools.ozone.moderation.defs#modEventReport', 'lex:tools.ozone.moderation.defs#modEventMute', + 'lex:tools.ozone.moderation.defs#modEventUnmute', + 'lex:tools.ozone.moderation.defs#modEventMuteReporter', + 'lex:tools.ozone.moderation.defs#modEventUnmuteReporter', 'lex:tools.ozone.moderation.defs#modEventReverseTakedown', 'lex:tools.ozone.moderation.defs#modEventUnmute', 'lex:tools.ozone.moderation.defs#modEventEmail', @@ -9337,6 +9471,11 @@ export const schemaDict = { description: "By default, we don't include muted subjects in the results. Set this to true to include them.", }, + onlyMuted: { + type: 'boolean', + description: + 'When set to true, only muted subjects and reporters will be returned.', + }, reviewState: { type: 'string', description: 'Specify when fetching subjects in a certain state', @@ -10301,6 +10440,8 @@ export const ids = { AppBskyUnspeccedDefs: 'app.bsky.unspecced.defs', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetSuggestionsSkeleton: + 'app.bsky.unspecced.getSuggestionsSkeleton', AppBskyUnspeccedGetTaggedSuggestions: 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedSearchActorsSkeleton: diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts index 7c8a13972fc..891a78edd9a 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts @@ -132,6 +132,7 @@ export type Preferences = ( | AdultContentPref | ContentLabelPref | SavedFeedsPref + | SavedFeedsPrefV2 | PersonalDetailsPref | FeedViewPref | ThreadViewPref @@ -178,6 +179,43 @@ export function validateContentLabelPref(v: unknown): ValidationResult { return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v) } +export interface SavedFeed { + id: string + type: 'feed' | 'list' | 'timeline' | (string & {}) + value: string + pinned: boolean + [k: string]: unknown +} + +export function isSavedFeed(v: unknown): v is SavedFeed { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeed' + ) +} + +export function validateSavedFeed(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeed', v) +} + +export interface SavedFeedsPrefV2 { + items: SavedFeed[] + [k: string]: unknown +} + +export function isSavedFeedsPrefV2(v: unknown): v is SavedFeedsPrefV2 { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.actor.defs#savedFeedsPrefV2' + ) +} + +export function validateSavedFeedsPrefV2(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.actor.defs#savedFeedsPrefV2', v) +} + export interface SavedFeedsPref { pinned: string[] saved: string[] diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts index 65878f7b12f..0198b23d790 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/searchActorsTypeahead.ts @@ -14,8 +14,6 @@ export interface QueryParams { term?: string /** Search query prefix; not a full query string. */ q?: string - /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ - viewer?: string limit: number } diff --git a/packages/pds/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts b/packages/pds/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts new file mode 100644 index 00000000000..6a18a56358c --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as AppBskyUnspeccedDefs from './defs' + +export interface QueryParams { + /** DID of the account making the request (not included for public/unauthenticated queries). Used to boost followed accounts in ranking. */ + viewer?: string + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + actors: AppBskyUnspeccedDefs.SkeletonSearchActor[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts index 3952959fe5e..a766391a971 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts @@ -14,6 +14,7 @@ export interface InputSchema { /** Handle or other identifier supported by the server for the authenticating user. */ identifier: string password: string + authFactorToken?: string [k: string]: unknown } @@ -25,6 +26,7 @@ export interface OutputSchema { didDoc?: {} email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean [k: string]: unknown } @@ -42,7 +44,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string - error?: 'AccountTakedown' + error?: 'AccountTakedown' | 'AuthFactorTokenRequired' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts index 5a8c40b947e..a12a6a7af5f 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts @@ -17,6 +17,7 @@ export interface OutputSchema { did: string email?: string emailConfirmed?: boolean + emailAuthFactor?: boolean didDoc?: {} [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts index 5473d7571e9..34fc7421979 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -12,6 +12,7 @@ export interface QueryParams {} export interface InputSchema { email: string + emailAuthFactor?: boolean /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ token?: string [k: string]: unknown diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts index b150d735f6c..d5cfd9f5530 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/defs.ts @@ -22,6 +22,9 @@ export interface ModEventView { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -61,6 +64,9 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventUnmute + | ModEventMuteReporter + | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert @@ -105,6 +111,7 @@ export interface SubjectStatusView { /** Sticky comment on the subject. */ comment?: string muteUntil?: string + muteReportingUntil?: string lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string @@ -237,6 +244,8 @@ export function validateModEventComment(v: unknown): ValidationResult { /** Report a subject */ export interface ModEventReport { comment?: string + /** Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject. */ + isReporterMuted?: boolean reportType: ComAtprotoModerationDefs.ReasonType [k: string]: unknown } @@ -346,6 +355,53 @@ export function validateModEventUnmute(v: unknown): ValidationResult { return lexicons.validate('tools.ozone.moderation.defs#modEventUnmute', v) } +/** Mute incoming reports from an account */ +export interface ModEventMuteReporter { + comment?: string + /** Indicates how long the account should remain muted. */ + durationInHours: number + [k: string]: unknown +} + +export function isModEventMuteReporter(v: unknown): v is ModEventMuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventMuteReporter' + ) +} + +export function validateModEventMuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventMuteReporter', + v, + ) +} + +/** Unmute incoming reports from an account */ +export interface ModEventUnmuteReporter { + /** Describe reasoning behind the reversal. */ + comment?: string + [k: string]: unknown +} + +export function isModEventUnmuteReporter( + v: unknown, +): v is ModEventUnmuteReporter { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'tools.ozone.moderation.defs#modEventUnmuteReporter' + ) +} + +export function validateModEventUnmuteReporter(v: unknown): ValidationResult { + return lexicons.validate( + 'tools.ozone.moderation.defs#modEventUnmuteReporter', + v, + ) +} + /** Keep a log of outgoing email to a user */ export interface ModEventEmail { /** The subject line of the email sent to the user. */ diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/emitEvent.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/emitEvent.ts index e3f502bd2eb..0b8737ccafd 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/emitEvent.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/emitEvent.ts @@ -22,6 +22,9 @@ export interface InputSchema { | ToolsOzoneModerationDefs.ModEventLabel | ToolsOzoneModerationDefs.ModEventReport | ToolsOzoneModerationDefs.ModEventMute + | ToolsOzoneModerationDefs.ModEventUnmute + | ToolsOzoneModerationDefs.ModEventMuteReporter + | ToolsOzoneModerationDefs.ModEventUnmuteReporter | ToolsOzoneModerationDefs.ModEventReverseTakedown | ToolsOzoneModerationDefs.ModEventUnmute | ToolsOzoneModerationDefs.ModEventEmail diff --git a/packages/pds/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts b/packages/pds/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts index 188749411fc..aece00e5626 100644 --- a/packages/pds/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +++ b/packages/pds/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts @@ -23,6 +23,8 @@ export interface QueryParams { reviewedBefore?: string /** By default, we don't include muted subjects in the results. Set this to true to include them. */ includeMuted?: boolean + /** When set to true, only muted subjects and reporters will be returned. */ + onlyMuted?: boolean /** Specify when fetching subjects in a certain state */ reviewState?: string ignoreSubjects?: string[] diff --git a/packages/pds/src/pipethrough.ts b/packages/pds/src/pipethrough.ts index 59998d5b81b..4f54e1f84aa 100644 --- a/packages/pds/src/pipethrough.ts +++ b/packages/pds/src/pipethrough.ts @@ -9,6 +9,14 @@ import { httpLogger } from './logger' import { getServiceEndpoint, noUndefinedVals } from '@atproto/common' import AppContext from './context' +type PipethroughOptions = { + /** + * Request headers to pass-through, in addition to those defined in + * {@link REQ_HEADERS_TO_FORWARD} + */ + reqHeadersToForward?: string[] +} + const defaultService = ( ctx: AppContext, path: string, @@ -39,12 +47,14 @@ export const pipethrough = async ( req: express.Request, requester?: string, audOverride?: string, + options?: PipethroughOptions, ): Promise => { const { url, headers } = await createUrlAndHeaders( ctx, req, requester, audOverride, + options, ) const reqInit: RequestInit = { headers, @@ -106,6 +116,7 @@ export const createUrlAndHeaders = async ( req: express.Request, requester?: string, audOverride?: string, + options?: PipethroughOptions, ): Promise<{ url: URL; headers: { authorization?: string } }> => { const proxyTo = await parseProxyHeader(ctx, req) const defaultProxy = defaultService(ctx, req.path) @@ -121,8 +132,11 @@ export const createUrlAndHeaders = async ( const headers = requester ? (await ctx.serviceAuthHeaders(requester, aud)).headers : {} + const allowedHeaders = REQ_HEADERS_TO_FORWARD.concat( + options?.reqHeadersToForward ?? [], + ) // forward select headers to upstream services - for (const header of REQ_HEADERS_TO_FORWARD) { + for (const header of allowedHeaders) { const val = req.headers[header] if (val) { headers[header] = val diff --git a/packages/pds/tests/handle-validation.test.ts b/packages/pds/tests/handle-validation.test.ts index c39f7db18de..a3565d4d314 100644 --- a/packages/pds/tests/handle-validation.test.ts +++ b/packages/pds/tests/handle-validation.test.ts @@ -25,4 +25,32 @@ describe('handle validation', () => { expect(isValidTld('atproto.onion')).toBe(false) expect(isValidTld('atproto.internal')).toBe(false) }) + + it('validates handle length', () => { + const domains = [ + '.loooooooooooooooooong-pds-over18chars.mybsky.mydomain.com', + '.test', + ] + const expectThrow = (handle: string, err: string) => { + expect(() => ensureHandleServiceConstraints(handle, domains)).toThrow(err) + } + const expectNotThrow = (handle: string, memo: string) => { + expect(() => + ensureHandleServiceConstraints(handle, domains), + ).not.toThrow() + } + expectThrow('usernamepartover18c.test', 'Handle too long') + expectNotThrow( + 'u23456789012345678.test', + 'safe up to 18 chars in first segment of the handle', + ) + expectThrow( + 'usernamepartover18c.loooooooooooooooooong-pds-over18chars.mybsky.mydomain.com', + 'Handle too long', + ) + expectNotThrow( + 'u23456789012345678.loooooooooooooooooong-pds-over18chars.mybsky.mydomain.com', + 'safe long domain in the handle', + ) + }) }) diff --git a/packages/pds/tests/proxied/__snapshots__/admin.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/admin.test.ts.snap index c9f80771905..b19b7645406 100644 --- a/packages/pds/tests/proxied/__snapshots__/admin.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/admin.test.ts.snap @@ -50,6 +50,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", "comment": "impersonation", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonOther", }, "id": 3, @@ -85,6 +86,7 @@ Array [ "creatorHandle": "alice.test", "event": Object { "$type": "tools.ozone.moderation.defs#modEventReport", + "isReporterMuted": false, "reportType": "com.atproto.moderation.defs#reasonSpam", }, "id": 1, @@ -167,7 +169,7 @@ Array [ "event": Object { "$type": "tools.ozone.moderation.defs#modEventTag", "add": Array [ - "lang:und", + "lang:en", ], "remove": Array [], }, @@ -221,7 +223,7 @@ Object { "subjectBlobCids": Array [], "subjectRepoHandle": "bob.test", "tags": Array [ - "lang:und", + "lang:en", ], "takendown": false, "updatedAt": "1970-01-01T00:00:00.000Z", diff --git a/packages/pds/tests/seeds/users-bulk.ts b/packages/pds/tests/seeds/users-bulk.ts index ec4e4b5a6f7..b7e3f9539f2 100644 --- a/packages/pds/tests/seeds/users-bulk.ts +++ b/packages/pds/tests/seeds/users-bulk.ts @@ -115,14 +115,14 @@ const users = [ { handle: 'kiana-schmitt39.test', displayName: null }, { handle: 'rhianna-stamm29.test', displayName: null }, { handle: 'tiara-mohr.test', displayName: null }, - { handle: 'eleazar-balistreri70.test', displayName: 'Gordon Weissnat' }, + { handle: 'eleazar-balist70.test', displayName: 'Gordon Weissnat' }, { handle: 'bettie-bogisich96.test', displayName: null }, { handle: 'lura-jacobi55.test', displayName: null }, { handle: 'santa-hermann78.test', displayName: 'Melissa Johnson' }, { handle: 'dylan61.test', displayName: null }, { handle: 'ryley-kerluke.test', displayName: 'Alexander Purdy' }, { handle: 'moises-bins8.test', displayName: null }, - { handle: 'angelita-schaefer27.test', displayName: null }, + { handle: 'angelita-schaef27.test', displayName: null }, { handle: 'natasha83.test', displayName: 'Dean Romaguera' }, { handle: 'sydni48.test', displayName: null }, { handle: 'darrion91.test', displayName: 'Jeanette Weimann' }, @@ -199,10 +199,10 @@ const users = [ { handle: 'melyna-zboncak.test', displayName: null }, { handle: 'rowan-parisian.test', displayName: 'Mr. Veronica Feeney' }, { handle: 'lois-blanda20.test', displayName: 'Todd Rolfson' }, - { handle: 'turner-balistreri76.test', displayName: null }, + { handle: 'turner-bali76.test', displayName: null }, { handle: 'dee-hoppe65.test', displayName: null }, { handle: 'nikko-rosenbaum60.test', displayName: 'Joann Gutmann' }, - { handle: 'cornell-romaguera53.test', displayName: null }, + { handle: 'cornell-rom53.test', displayName: null }, { handle: 'zack3.test', displayName: null }, { handle: 'fredrick41.test', displayName: 'Julius Kreiger' }, { handle: 'elwyn62.test', displayName: null }, @@ -240,6 +240,6 @@ const users = [ { handle: 'nayeli-koss73.test', displayName: 'Johnny Lang' }, { handle: 'cara-wiegand69.test', displayName: null }, { handle: 'gideon-ohara51.test', displayName: null }, - { handle: 'carolina-mcdermott77.test', displayName: 'Latoya Windler' }, + { handle: 'carolina-mcderm77.test', displayName: 'Latoya Windler' }, { handle: 'danyka90.test', displayName: 'Hope Kub' }, ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ceab7fd608..9b16a637e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,6 +583,9 @@ importers: kysely: specifier: ^0.22.0 version: 0.22.0 + lande: + specifier: ^1.0.10 + version: 1.0.10 multiformats: specifier: ^9.9.0 version: 9.9.0 @@ -8767,6 +8770,12 @@ packages: resolution: {integrity: sha512-TH+b56pVXQq0tsyooYLeNfV11j6ih7D50dyN8tkM0e7ndiUH28Nziojiog3qRFlmEj9XePYdZUrNJ2079Qjdow==} engines: {node: '>=14.0.0'} + /lande@1.0.10: + resolution: {integrity: sha512-yT52DQh+UV2pEp08jOYrA4drDv0DbjpiRyZYgl25ak9G2cVR2AimzrqkYQWrD9a7Ud+qkAcaiDDoNH9DXfHPmw==} + dependencies: + toygrad: 2.6.0 + dev: false + /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -10708,6 +10717,10 @@ packages: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + /toygrad@2.6.0: + resolution: {integrity: sha512-g4zBmlSbvzOE5FOILxYkAybTSxijKLkj1WoNqVGnbMcWDyj4wWQ+eYSr3ik7XOpIgMq/7eBcPRTJX3DM2E0YMg==} + dev: false + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: true