Add account preferences APIs ()

* Add lexicons for account preferences

* Move prefs to app.bsky, codegen

* Setup model and services for user prefs

* Setup xrpc methods for prefs

* Test preferences, fixes

* Tidy

* Tidy

---------

Co-authored-by: Devin Ivy <devinivy@gmail.com>
This commit is contained in:
Paul Frazee 2023-05-11 15:44:00 -05:00 committed by GitHub
parent 5fd5c869ea
commit df6ed7d5c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1080 additions and 1 deletions

@ -86,6 +86,31 @@
"following": {"type": "string", "format": "at-uri"},
"followedBy": {"type": "string", "format": "at-uri"}
}
},
"preferences": {
"type": "array",
"items": {
"type": "union",
"refs": [
"#adultContentPref",
"#contentLabelPref"
]
}
},
"adultContentPref": {
"type": "object",
"required": ["enabled"],
"properties": {
"enabled": {"type": "boolean", "default": false}
}
},
"contentLabelPref": {
"type": "object",
"required": ["label", "visibility"],
"properties": {
"label": {"type": "string"},
"visibility": {"type": "string", "knownValues": ["show", "warn", "hide"]}
}
}
}
}

@ -0,0 +1,28 @@
{
"lexicon": 1,
"id": "app.bsky.actor.getPreferences",
"defs": {
"main": {
"type": "query",
"description": "Get private preferences attached to the account.",
"parameters": {
"type": "params",
"properties": {
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["preferences"],
"properties": {
"preferences": {
"type": "ref",
"ref": "app.bsky.actor.defs#preferences"
}
}
}
}
}
}
}

@ -0,0 +1,23 @@
{
"lexicon": 1,
"id": "app.bsky.actor.putPreferences",
"defs": {
"main": {
"type": "procedure",
"description": "Sets the private preferences attached to the account.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["preferences"],
"properties": {
"preferences": {
"type": "ref",
"ref": "app.bsky.actor.defs#preferences"
}
}
}
}
}
}
}

@ -71,10 +71,12 @@ import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf
import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl'
import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos'
import * as AppBskyActorDefs from './types/app/bsky/actor/defs'
import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences'
import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile'
import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles'
import * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions'
import * as AppBskyActorProfile from './types/app/bsky/actor/profile'
import * as AppBskyActorPutPreferences from './types/app/bsky/actor/putPreferences'
import * as AppBskyActorSearchActors from './types/app/bsky/actor/searchActors'
import * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searchActorsTypeahead'
import * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
@ -177,10 +179,12 @@ export * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf
export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl'
export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos'
export * as AppBskyActorDefs from './types/app/bsky/actor/defs'
export * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences'
export * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile'
export * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles'
export * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions'
export * as AppBskyActorProfile from './types/app/bsky/actor/profile'
export * as AppBskyActorPutPreferences from './types/app/bsky/actor/putPreferences'
export * as AppBskyActorSearchActors from './types/app/bsky/actor/searchActors'
export * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searchActorsTypeahead'
export * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
@ -1012,6 +1016,17 @@ export class ActorNS {
this.profile = new ProfileRecord(service)
}
getPreferences(
params?: AppBskyActorGetPreferences.QueryParams,
opts?: AppBskyActorGetPreferences.CallOptions,
): Promise<AppBskyActorGetPreferences.Response> {
return this._service.xrpc
.call('app.bsky.actor.getPreferences', params, undefined, opts)
.catch((e) => {
throw AppBskyActorGetPreferences.toKnownErr(e)
})
}
getProfile(
params?: AppBskyActorGetProfile.QueryParams,
opts?: AppBskyActorGetProfile.CallOptions,
@ -1045,6 +1060,17 @@ export class ActorNS {
})
}
putPreferences(
data?: AppBskyActorPutPreferences.InputSchema,
opts?: AppBskyActorPutPreferences.CallOptions,
): Promise<AppBskyActorPutPreferences.Response> {
return this._service.xrpc
.call('app.bsky.actor.putPreferences', opts?.qp, data, opts)
.catch((e) => {
throw AppBskyActorPutPreferences.toKnownErr(e)
})
}
searchActors(
params?: AppBskyActorSearchActors.QueryParams,
opts?: AppBskyActorSearchActors.CallOptions,

@ -3509,6 +3509,66 @@ export const schemaDict = {
},
},
},
preferences: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.actor.defs#adultContentPref',
'lex:app.bsky.actor.defs#contentLabelPref',
],
},
},
adultContentPref: {
type: 'object',
required: ['enabled'],
properties: {
enabled: {
type: 'boolean',
default: false,
},
},
},
contentLabelPref: {
type: 'object',
required: ['label', 'visibility'],
properties: {
label: {
type: 'string',
},
visibility: {
type: 'string',
knownValues: ['show', 'warn', 'hide'],
},
},
},
},
},
AppBskyActorGetPreferences: {
lexicon: 1,
id: 'app.bsky.actor.getPreferences',
defs: {
main: {
type: 'query',
description: 'Get private preferences attached to the account.',
parameters: {
type: 'params',
properties: {},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorGetProfile: {
@ -3655,6 +3715,29 @@ export const schemaDict = {
},
},
},
AppBskyActorPutPreferences: {
lexicon: 1,
id: 'app.bsky.actor.putPreferences',
defs: {
main: {
type: 'procedure',
description: 'Sets the private preferences attached to the account.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorSearchActors: {
lexicon: 1,
id: 'app.bsky.actor.searchActors',
@ -5659,10 +5742,12 @@ export const ids = {
ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl',
ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos',
AppBskyActorDefs: 'app.bsky.actor.defs',
AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences',
AppBskyActorGetProfile: 'app.bsky.actor.getProfile',
AppBskyActorGetProfiles: 'app.bsky.actor.getProfiles',
AppBskyActorGetSuggestions: 'app.bsky.actor.getSuggestions',
AppBskyActorProfile: 'app.bsky.actor.profile',
AppBskyActorPutPreferences: 'app.bsky.actor.putPreferences',
AppBskyActorSearchActors: 'app.bsky.actor.searchActors',
AppBskyActorSearchActorsTypeahead: 'app.bsky.actor.searchActorsTypeahead',
AppBskyEmbedExternal: 'app.bsky.embed.external',

@ -103,3 +103,44 @@ export function isViewerState(v: unknown): v is ViewerState {
export function validateViewerState(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#viewerState', v)
}
export type Preferences = (
| AdultContentPref
| ContentLabelPref
| { $type: string; [k: string]: unknown }
)[]
export interface AdultContentPref {
enabled: boolean
[k: string]: unknown
}
export function isAdultContentPref(v: unknown): v is AdultContentPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#adultContentPref'
)
}
export function validateAdultContentPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#adultContentPref', v)
}
export interface ContentLabelPref {
label: string
visibility: 'show' | 'warn' | 'hide' | (string & {})
[k: string]: unknown
}
export function isContentLabelPref(v: unknown): v is ContentLabelPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#contentLabelPref'
)
}
export function validateContentLabelPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v)
}

@ -0,0 +1,34 @@
/**
* 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 AppBskyActorDefs from './defs'
export interface QueryParams {}
export type InputSchema = undefined
export interface OutputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -0,0 +1,33 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as AppBskyActorDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -64,9 +64,11 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos'
import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate'
import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl'
import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos'
import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences'
import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile'
import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles'
import * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions'
import * as AppBskyActorPutPreferences from './types/app/bsky/actor/putPreferences'
import * as AppBskyActorSearchActors from './types/app/bsky/actor/searchActors'
import * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searchActorsTypeahead'
import * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed'
@ -686,6 +688,13 @@ export class ActorNS {
this._server = server
}
getPreferences<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorGetPreferences.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.actor.getPreferences' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getProfile<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorGetProfile.Handler<ExtractAuth<AV>>>,
) {
@ -707,6 +716,13 @@ export class ActorNS {
return this._server.xrpc.method(nsid, cfg)
}
putPreferences<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorPutPreferences.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.actor.putPreferences' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
searchActors<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorSearchActors.Handler<ExtractAuth<AV>>>,
) {

@ -3417,6 +3417,66 @@ export const schemaDict = {
},
},
},
preferences: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.actor.defs#adultContentPref',
'lex:app.bsky.actor.defs#contentLabelPref',
],
},
},
adultContentPref: {
type: 'object',
required: ['enabled'],
properties: {
enabled: {
type: 'boolean',
default: false,
},
},
},
contentLabelPref: {
type: 'object',
required: ['label', 'visibility'],
properties: {
label: {
type: 'string',
},
visibility: {
type: 'string',
knownValues: ['show', 'warn', 'hide'],
},
},
},
},
},
AppBskyActorGetPreferences: {
lexicon: 1,
id: 'app.bsky.actor.getPreferences',
defs: {
main: {
type: 'query',
description: 'Get private preferences attached to the account.',
parameters: {
type: 'params',
properties: {},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorGetProfile: {
@ -3563,6 +3623,29 @@ export const schemaDict = {
},
},
},
AppBskyActorPutPreferences: {
lexicon: 1,
id: 'app.bsky.actor.putPreferences',
defs: {
main: {
type: 'procedure',
description: 'Sets the private preferences attached to the account.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorSearchActors: {
lexicon: 1,
id: 'app.bsky.actor.searchActors',
@ -5189,10 +5272,12 @@ export const ids = {
ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl',
ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos',
AppBskyActorDefs: 'app.bsky.actor.defs',
AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences',
AppBskyActorGetProfile: 'app.bsky.actor.getProfile',
AppBskyActorGetProfiles: 'app.bsky.actor.getProfiles',
AppBskyActorGetSuggestions: 'app.bsky.actor.getSuggestions',
AppBskyActorProfile: 'app.bsky.actor.profile',
AppBskyActorPutPreferences: 'app.bsky.actor.putPreferences',
AppBskyActorSearchActors: 'app.bsky.actor.searchActors',
AppBskyActorSearchActorsTypeahead: 'app.bsky.actor.searchActorsTypeahead',
AppBskyEmbedExternal: 'app.bsky.embed.external',

@ -101,3 +101,44 @@ export function isViewerState(v: unknown): v is ViewerState {
export function validateViewerState(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#viewerState', v)
}
export type Preferences = (
| AdultContentPref
| ContentLabelPref
| { $type: string; [k: string]: unknown }
)[]
export interface AdultContentPref {
enabled: boolean
[k: string]: unknown
}
export function isAdultContentPref(v: unknown): v is AdultContentPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#adultContentPref'
)
}
export function validateAdultContentPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#adultContentPref', v)
}
export interface ContentLabelPref {
label: string
visibility: 'show' | 'warn' | 'hide' | (string & {})
[k: string]: unknown
}
export function isContentLabelPref(v: unknown): v is ContentLabelPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#contentLabelPref'
)
}
export function validateContentLabelPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v)
}

@ -0,0 +1,40 @@
/**
* 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 } from '@atproto/xrpc-server'
import * as AppBskyActorDefs from './defs'
export interface QueryParams {}
export type InputSchema = undefined
export interface OutputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess
export type Handler<HA extends HandlerAuth = never> = (ctx: {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,36 @@
/**
* 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 } from '@atproto/xrpc-server'
import * as AppBskyActorDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | void
export type Handler<HA extends HandlerAuth = never> = (ctx: {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,19 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.actor.getPreferences({
auth: ctx.accessVerifier,
handler: async ({ auth }) => {
const requester = auth.credentials.did
const { services, db } = ctx
const preferences = await services
.account(db)
.getPreferences(requester, 'app.bsky')
return {
encoding: 'application/json',
body: { preferences },
}
},
})
}

@ -0,0 +1,9 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import getPreferences from './getPreferences'
import putPreferences from './putPreferences'
export default function (server: Server, ctx: AppContext) {
getPreferences(server, ctx)
putPreferences(server, ctx)
}

@ -0,0 +1,28 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { UserPreference } from '../../../../services/account'
import { InvalidRequestError } from '@atproto/xrpc-server'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.actor.putPreferences({
auth: ctx.accessVerifierCheckTakedown,
handler: async ({ auth, input }) => {
const { preferences } = input.body
const requester = auth.credentials.did
const { services, db } = ctx
const checkedPreferences: UserPreference[] = []
for (const pref of preferences) {
if (typeof pref.$type === 'string') {
checkedPreferences.push(pref as UserPreference)
} else {
throw new InvalidRequestError('Preference is missing a $type')
}
}
await db.transaction(async (tx) => {
await services
.account(tx)
.putPreferences(requester, checkedPreferences, 'app.bsky')
})
},
})
}

@ -1,9 +1,11 @@
import { Server } from '../../../lexicon'
import AppContext from '../../../context'
import actor from './actor'
import graph from './graph'
import notification from './notification'
export default function (server: Server, ctx: AppContext) {
actor(server, ctx)
graph(server, ctx)
notification(server, ctx)
}

@ -1,6 +1,7 @@
import { Kysely } from 'kysely'
import * as userAccount from './tables/user-account'
import * as userState from './tables/user-state'
import * as userPref from './tables/user-pref'
import * as didHandle from './tables/did-handle'
import * as repoRoot from './tables/repo-root'
import * as refreshToken from './tables/refresh-token'
@ -27,6 +28,7 @@ export type DatabaseSchemaType = appView.DatabaseSchemaType &
appMigration.PartialDB &
userAccount.PartialDB &
userState.PartialDB &
userPref.PartialDB &
didHandle.PartialDB &
refreshToken.PartialDB &
appPassword.PartialDB &

@ -0,0 +1,26 @@
import { Kysely } from 'kysely'
import { Dialect } from '..'
export async function up(db: Kysely<unknown>, dialect: Dialect): Promise<void> {
let builder = db.schema.createTable('user_pref')
builder =
dialect === 'pg'
? builder.addColumn('id', 'bigserial', (col) => col.primaryKey())
: builder.addColumn('id', 'integer', (col) =>
col.autoIncrement().primaryKey(),
)
await builder
.addColumn('did', 'varchar', (col) => col.notNull())
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('valueJson', 'text', (col) => col.notNull())
.execute()
await db.schema
.createIndex('user_pref_did_idx')
.on('user_pref')
.column('did')
.execute()
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('user_pref').execute()
}

@ -47,3 +47,4 @@ export * as _20230508T193807762Z from './20230508T193807762Z-acct-deletion-index
export * as _20230508T232711152Z from './20230508T232711152Z-disable-account-invites'
export * as _20230509T192324175Z from './20230509T192324175Z-seq-invalidated'
export * as _20230511T154721392Z from './20230511T154721392Z-mute-lists'
export * as _20230511T171739449Z from './20230511T171739449Z-actor-preferences'

@ -0,0 +1,12 @@
import { GeneratedAlways } from 'kysely'
export interface UserPref {
id: GeneratedAlways<number>
did: string
name: string
valueJson: string // json
}
export const tableName = 'user_pref'
export type PartialDB = { [tableName]: UserPref }

@ -67,9 +67,11 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos'
import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate'
import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl'
import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos'
import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences'
import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile'
import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles'
import * as AppBskyActorGetSuggestions from './types/app/bsky/actor/getSuggestions'
import * as AppBskyActorPutPreferences from './types/app/bsky/actor/putPreferences'
import * as AppBskyActorSearchActors from './types/app/bsky/actor/searchActors'
import * as AppBskyActorSearchActorsTypeahead from './types/app/bsky/actor/searchActorsTypeahead'
import * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed'
@ -724,6 +726,13 @@ export class ActorNS {
this._server = server
}
getPreferences<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorGetPreferences.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.actor.getPreferences' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getProfile<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorGetProfile.Handler<ExtractAuth<AV>>>,
) {
@ -745,6 +754,13 @@ export class ActorNS {
return this._server.xrpc.method(nsid, cfg)
}
putPreferences<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorPutPreferences.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.actor.putPreferences' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
searchActors<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyActorSearchActors.Handler<ExtractAuth<AV>>>,
) {

@ -3509,6 +3509,66 @@ export const schemaDict = {
},
},
},
preferences: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.actor.defs#adultContentPref',
'lex:app.bsky.actor.defs#contentLabelPref',
],
},
},
adultContentPref: {
type: 'object',
required: ['enabled'],
properties: {
enabled: {
type: 'boolean',
default: false,
},
},
},
contentLabelPref: {
type: 'object',
required: ['label', 'visibility'],
properties: {
label: {
type: 'string',
},
visibility: {
type: 'string',
knownValues: ['show', 'warn', 'hide'],
},
},
},
},
},
AppBskyActorGetPreferences: {
lexicon: 1,
id: 'app.bsky.actor.getPreferences',
defs: {
main: {
type: 'query',
description: 'Get private preferences attached to the account.',
parameters: {
type: 'params',
properties: {},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorGetProfile: {
@ -3655,6 +3715,29 @@ export const schemaDict = {
},
},
},
AppBskyActorPutPreferences: {
lexicon: 1,
id: 'app.bsky.actor.putPreferences',
defs: {
main: {
type: 'procedure',
description: 'Sets the private preferences attached to the account.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['preferences'],
properties: {
preferences: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#preferences',
},
},
},
},
},
},
},
AppBskyActorSearchActors: {
lexicon: 1,
id: 'app.bsky.actor.searchActors',
@ -5659,10 +5742,12 @@ export const ids = {
ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl',
ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos',
AppBskyActorDefs: 'app.bsky.actor.defs',
AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences',
AppBskyActorGetProfile: 'app.bsky.actor.getProfile',
AppBskyActorGetProfiles: 'app.bsky.actor.getProfiles',
AppBskyActorGetSuggestions: 'app.bsky.actor.getSuggestions',
AppBskyActorProfile: 'app.bsky.actor.profile',
AppBskyActorPutPreferences: 'app.bsky.actor.putPreferences',
AppBskyActorSearchActors: 'app.bsky.actor.searchActors',
AppBskyActorSearchActorsTypeahead: 'app.bsky.actor.searchActorsTypeahead',
AppBskyEmbedExternal: 'app.bsky.embed.external',

@ -103,3 +103,44 @@ export function isViewerState(v: unknown): v is ViewerState {
export function validateViewerState(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#viewerState', v)
}
export type Preferences = (
| AdultContentPref
| ContentLabelPref
| { $type: string; [k: string]: unknown }
)[]
export interface AdultContentPref {
enabled: boolean
[k: string]: unknown
}
export function isAdultContentPref(v: unknown): v is AdultContentPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#adultContentPref'
)
}
export function validateAdultContentPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#adultContentPref', v)
}
export interface ContentLabelPref {
label: string
visibility: 'show' | 'warn' | 'hide' | (string & {})
[k: string]: unknown
}
export function isContentLabelPref(v: unknown): v is ContentLabelPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#contentLabelPref'
)
}
export function validateContentLabelPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#contentLabelPref', v)
}

@ -0,0 +1,40 @@
/**
* 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 } from '@atproto/xrpc-server'
import * as AppBskyActorDefs from './defs'
export interface QueryParams {}
export type InputSchema = undefined
export interface OutputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess
export type Handler<HA extends HandlerAuth = never> = (ctx: {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,36 @@
/**
* 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 } from '@atproto/xrpc-server'
import * as AppBskyActorDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
preferences: AppBskyActorDefs.Preferences
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | void
export type Handler<HA extends HandlerAuth = never> = (ctx: {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput

@ -2,7 +2,7 @@ import { sql } from 'kysely'
import { dbLogger as log } from '../../logger'
import Database from '../../db'
import * as scrypt from '../../db/scrypt'
import { UserAccount, UserAccountEntry } from '../../db/tables/user-account'
import { UserAccountEntry } from '../../db/tables/user-account'
import { DidHandle } from '../../db/tables/did-handle'
import { RepoRoot } from '../../db/tables/repo-root'
import {
@ -505,8 +505,65 @@ export class AccountService {
.executeTakeFirst()
return res?.lastSeenNotifs
}
async getPreferences(
did: string,
namespace?: string,
): Promise<UserPreference[]> {
const prefsRes = await this.db.db
.selectFrom('user_pref')
.where('did', '=', did)
.orderBy('id')
.selectAll()
.execute()
return prefsRes
.filter((pref) => !namespace || matchNamespace(namespace, pref.name))
.map((pref) => JSON.parse(pref.valueJson))
}
async putPreferences(
did: string,
values: UserPreference[],
namespace: string,
): Promise<void> {
this.db.assertTransaction()
if (!values.every((value) => matchNamespace(namespace, value.$type))) {
throw new InvalidRequestError(
`Some preferences are not in the ${namespace} namespace`,
)
}
// get all current prefs for user and prep new pref rows
const allPrefs = await this.db.db
.selectFrom('user_pref')
.where('did', '=', did)
.select(['id', 'name'])
.execute()
const putPrefs = values.map((value) => {
return {
did,
name: value.$type,
valueJson: JSON.stringify(value),
}
})
const allPrefIdsInNamespace = allPrefs
.filter((pref) => matchNamespace(namespace, pref.name))
.map((pref) => pref.id)
// replace all prefs in given namespace
if (allPrefIdsInNamespace.length) {
await this.db.db
.deleteFrom('user_pref')
.where('did', '=', did)
.where('id', 'in', allPrefIdsInNamespace)
.execute()
}
if (putPrefs.length) {
await this.db.db.insertInto('user_pref').values(putPrefs).execute()
}
}
}
export type UserPreference = Record<string, unknown> & { $type: string }
type CodeDetail = {
code: string
available: number
@ -532,3 +589,7 @@ export class ListKeyset extends TimeCidKeyset<{
return { primary: result.indexedAt, secondary: result.handle }
}
}
const matchNamespace = (namespace: string, fullname: string) => {
return fullname === namespace || fullname.startsWith(`${namespace}.`)
}

@ -0,0 +1,188 @@
import AtpAgent from '@atproto/api'
import { CloseFn, runTestServer, TestServerInfo } from './_util'
import { SeedClient } from './seeds/client'
import usersSeed from './seeds/users'
describe('user preferences', () => {
let server: TestServerInfo
let close: CloseFn
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
server = await runTestServer({
dbPostgresSchema: 'preferences',
})
close = server.close
agent = new AtpAgent({ service: server.url })
sc = new SeedClient(agent)
await usersSeed(sc)
})
afterAll(async () => {
await close()
})
it('requires auth to set or put preferences.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
],
})
await expect(tryPut).rejects.toThrow('Authentication Required')
const tryGet = agent.api.app.bsky.actor.getPreferences()
await expect(tryGet).rejects.toThrow('Authentication Required')
})
it('gets preferences, before any are set.', async () => {
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [],
})
})
it('only gets preferences in app.bsky namespace.', async () => {
const { db, services } = server.ctx
await db.transaction(async (tx) => {
await services
.account(tx)
.putPreferences(
sc.dids.alice,
[{ $type: 'com.atproto.server.defs#unknown' }],
'com.atproto',
)
})
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({ preferences: [] })
})
it('puts preferences, all creates.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'cats',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'cats',
visibility: 'warn',
},
],
})
// Ensure other prefs were not clobbered
const { db, services } = server.ctx
const otherPrefs = await services
.account(db)
.getPreferences(sc.dids.alice, 'com.atproto')
expect(otherPrefs).toEqual([{ $type: 'com.atproto.server.defs#unknown' }])
})
it('puts preferences, updates and removals.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: true },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: true },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'warn',
},
],
})
})
it('puts preferences, clearing them.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{ preferences: [] },
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({ preferences: [] })
})
it('fails putting preferences outside namespace.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'com.atproto.server.defs#unknown',
hello: 'world',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
await expect(tryPut).rejects.toThrow(
'Some preferences are not in the app.bsky namespace',
)
})
it('fails putting preferences without $type.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
label: 'dogs',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
await expect(tryPut).rejects.toThrow(
'Input/preferences/1 must be an object which includes the "$type" property',
)
})
})