Push labels to PDS ()

* add lexicon for unspecced applyLabels procedure

* implement label push to pds via unspecced.applyLabels

* add hive retry to bsky appview

* build

* update applyLabels to work with raw label data

* update bsky hive labeler

* remove build
This commit is contained in:
devin ivy 2023-08-03 12:25:56 -04:00 committed by GitHub
parent 450dff7fa3
commit e5e24d510e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 595 additions and 39 deletions
.github/workflows
lexicons/app/bsky/unspecced
packages
api/src/client
index.tslexicons.ts
types
app/bsky/unspecced
com/atproto
bsky/src
auth.ts
indexer
labeler
lexicon
index.tslexicons.ts
types
app/bsky/unspecced
com/atproto
pds
src
app-view/api/app/bsky
lexicon
index.tslexicons.ts
types
app/bsky/unspecced
com/atproto
tests/labeler

@ -3,7 +3,6 @@ on:
push:
branches:
- main
- indexer-redis-setup
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}

@ -0,0 +1,23 @@
{
"lexicon": 1,
"id": "app.bsky.unspecced.applyLabels",
"defs": {
"main": {
"type": "procedure",
"description": "Allow a labeler to apply labels directly.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["labels"],
"properties": {
"labels": {
"type": "array",
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
}
}
}
}
}
}
}

@ -122,6 +122,7 @@ import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notificatio
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
import * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet'
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
@ -241,6 +242,7 @@ export * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notificatio
export * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
export * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
export * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet'
export * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
export * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
export * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
@ -2036,6 +2038,17 @@ export class UnspeccedNS {
this._service = service
}
applyLabels(
data?: AppBskyUnspeccedApplyLabels.InputSchema,
opts?: AppBskyUnspeccedApplyLabels.CallOptions,
): Promise<AppBskyUnspeccedApplyLabels.Response> {
return this._service.xrpc
.call('app.bsky.unspecced.applyLabels', opts?.qp, data, opts)
.catch((e) => {
throw AppBskyUnspeccedApplyLabels.toKnownErr(e)
})
}
getPopular(
params?: AppBskyUnspeccedGetPopular.QueryParams,
opts?: AppBskyUnspeccedGetPopular.CallOptions,

@ -536,7 +536,6 @@ export const schemaDict = {
},
moderation: {
type: 'object',
required: [],
properties: {
currentAction: {
type: 'ref',
@ -708,7 +707,7 @@ export const schemaDict = {
note: {
type: 'string',
description:
'Additionally add a note describing why the invites were disabled',
'Additionally add a note describing why the invites were enabled',
},
},
},
@ -1668,7 +1667,7 @@ export const schemaDict = {
},
reasonSexual: {
type: 'token',
description: 'Unwanted or mis-labeled sexual content',
description: 'Unwanted or mislabeled sexual content',
},
reasonRude: {
type: 'token',
@ -6294,6 +6293,32 @@ export const schemaDict = {
},
},
},
AppBskyUnspeccedApplyLabels: {
lexicon: 1,
id: 'app.bsky.unspecced.applyLabels',
defs: {
main: {
type: 'procedure',
description: 'Allow a labeler to apply labels directly.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['labels'],
properties: {
labels: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#label',
},
},
},
},
},
},
},
},
AppBskyUnspeccedGetPopular: {
lexicon: 1,
id: 'app.bsky.unspecced.getPopular',
@ -6561,6 +6586,7 @@ export const ids = {
'app.bsky.notification.listNotifications',
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
AppBskyUnspeccedGetPopularFeedGenerators:
'app.bsky.unspecced.getPopularFeedGenerators',

@ -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 ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
export interface QueryParams {}
export interface InputSchema {
labels: ComAtprotoLabelDefs.Label[]
[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
}

@ -11,7 +11,7 @@ export interface QueryParams {}
export interface InputSchema {
account: string
/** Additionally add a note describing why the invites were disabled */
/** Additionally add a note describing why the invites were enabled */
note?: string
[k: string]: unknown
}

@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
/** Misleading identity, affiliation, or content */
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
/** Unwanted or mis-labeled sexual content */
/** Unwanted or mislabeled sexual content */
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'

@ -73,6 +73,16 @@ export const parseBasicAuth = (
return { username, password }
}
export const buildBasicAuth = (username: string, password: string): string => {
return (
BASIC +
uint8arrays.toString(
uint8arrays.fromString(`${username}:${password}`, 'utf8'),
'base64pad',
)
)
}
export const getJwtStrFromReq = (req: express.Request): string | null => {
const { authorization = '' } = req.headers
if (!authorization.startsWith(BEARER)) {

@ -15,6 +15,7 @@ export interface IndexerConfigValues {
labelerDid: string
hiveApiKey?: string
labelerKeywords: Record<string, string>
labelerPushUrl?: string
indexerConcurrency?: number
indexerPartitionIds: number[]
indexerPartitionBatchSize?: number
@ -54,6 +55,8 @@ export class IndexerConfig {
DAY,
)
const labelerDid = process.env.LABELER_DID || 'did:example:labeler'
const labelerPushUrl =
overrides?.labelerPushUrl || process.env.LABELER_PUSH_URL || undefined
const hiveApiKey = process.env.HIVE_API_KEY || undefined
const indexerPartitionIds =
overrides?.indexerPartitionIds ||
@ -84,6 +87,7 @@ export class IndexerConfig {
didCacheStaleTTL,
didCacheMaxTTL,
labelerDid,
labelerPushUrl,
hiveApiKey,
indexerPartitionIds,
indexerConcurrency,
@ -139,6 +143,10 @@ export class IndexerConfig {
return this.cfg.labelerDid
}
get labelerPushUrl() {
return this.cfg.labelerPushUrl
}
get hiveApiKey() {
return this.cfg.hiveApiKey
}

@ -1,5 +1,6 @@
import stream from 'stream'
import { AtUri } from '@atproto/uri'
import { AtpAgent } from '@atproto/api'
import { cidForRecord } from '@atproto/repo'
import { dedupe, getFieldsFromRecord } from './util'
import { labelerLogger as log } from '../logger'
@ -8,9 +9,11 @@ import Database from '../db'
import { IdResolver } from '@atproto/identity'
import { BackgroundQueue } from '../background'
import { IndexerConfig } from '../indexer/config'
import { buildBasicAuth } from '../auth'
export abstract class Labeler {
public backgroundQueue: BackgroundQueue
public pushAgent?: AtpAgent
constructor(
protected ctx: {
db: Database
@ -20,6 +23,14 @@ export abstract class Labeler {
},
) {
this.backgroundQueue = ctx.backgroundQueue
if (ctx.cfg.labelerPushUrl) {
const url = new URL(ctx.cfg.labelerPushUrl)
this.pushAgent = new AtpAgent({ service: url.origin })
this.pushAgent.api.setHeader(
'authorization',
buildBasicAuth(url.username, url.password),
)
}
}
processRecord(uri: AtUri, obj: unknown) {
@ -51,6 +62,23 @@ export abstract class Labeler {
.values(rows)
.onConflict((oc) => oc.doNothing())
.execute()
if (this.pushAgent) {
const agent = this.pushAgent
try {
await agent.api.app.bsky.unspecced.applyLabels({ labels: rows })
} catch (err) {
log.error(
{
err,
uri: uri.toString(),
labels,
receiver: agent.service.toString(),
},
'failed to push labels',
)
}
}
}
async labelRecord(uri: AtUri, obj: unknown): Promise<string[]> {

@ -7,6 +7,7 @@ import { IdResolver } from '@atproto/identity'
import Database from '../db'
import { BackgroundQueue } from '../background'
import { IndexerConfig } from '../indexer/config'
import { retryHttp } from '../util/retry'
const HIVE_ENDPOINT = 'https://api.thehive.ai/api/v2/task/sync'
@ -51,13 +52,16 @@ export const makeHiveReq = async (
): Promise<HiveRespClass[]> => {
const form = new FormData()
form.append('media', blob)
const res = await axios.post(HIVE_ENDPOINT, form, {
headers: {
'Content-Type': 'multipart/form-data',
authorization: `token ${hiveApiKey}`,
accept: 'application/json',
},
})
const res = await retryHttp(() =>
axios.post(HIVE_ENDPOINT, form, {
headers: {
'Content-Type': 'multipart/form-data',
authorization: `token ${hiveApiKey}`,
accept: 'application/json',
},
}),
)
return respToClasses(res.data)
}
@ -73,30 +77,101 @@ export const respToClasses = (res: HiveResp): HiveRespClass[] => {
return classes
}
// sexual: https://docs.thehive.ai/docs/sexual-content
// Matches only one (or none) of: porn, sexual, nudity
//
// porn: sexual and nudity. including both explicit activity or full-frontal and suggestive/intent
// sexual: sexually suggestive, not explicit; may include some forms of nudity
// nudity: non-sexual nudity (eg, artistic, possibly some photographic)
//
// hive docs/definitions: https://docs.thehive.ai/docs/sexual-content
export const sexualLabels = (classes: HiveRespClass[]): string[] => {
const scores = {}
for (const cls of classes) {
scores[cls.class] = cls.score
}
// first check if porn...
for (const pornClass of [
'yes_sexual_activity',
'animal_genitalia_and_human',
'yes_realistic_nsfw',
]) {
if (scores[pornClass] >= 0.9) {
return ['porn']
}
}
if (scores['general_nsfw'] >= 0.9) {
// special case for some anime examples
if (scores['animated_animal_genitalia'] >= 0.5) {
return ['porn']
}
// special case for some pornographic/explicit classic drawings
if (scores['yes_undressed'] >= 0.9 && scores['yes_sexual_activity'] > 0.9) {
return ['porn']
}
}
// then check for sexual suggestive (which may include nudity)...
for (const sexualClass of ['yes_sexual_intent', 'yes_sex_toy']) {
if (scores[sexualClass] >= 0.9) {
return ['sexual']
}
}
if (scores['yes_undressed'] >= 0.9) {
// special case for bondage examples
if (scores['yes_sex_toy'] > 0.75) {
return ['sexual']
}
}
// then non-sexual nudity...
for (const nudityClass of [
'yes_male_nudity',
'yes_female_nudity',
'yes_undressed',
]) {
if (scores[nudityClass] >= 0.9) {
return ['nudity']
}
}
// then finally flag remaining "underwear" images in to sexually suggestive
// (after non-sexual content already labeled above)
for (const nudityClass of ['yes_male_underwear', 'yes_female_underwear']) {
if (scores[nudityClass] >= 0.9) {
// TODO: retaining 'underwear' label for a short time to help understand
// the impact of labeling all "underwear" as "sexual". This *will* be
// pulling in somewhat non-sexual content in to "sexual" label.
return ['sexual', 'underwear']
}
}
return []
}
// gore and violence: https://docs.thehive.ai/docs/class-descriptions-violence-gore
// iconography: https://docs.thehive.ai/docs/class-descriptions-hate-bullying
const labelForClass = {
yes_sexual_activity: 'porn',
animal_genitalia_and_human: 'porn', // for some reason not included in 'yes_sexual_activity'
yes_male_nudity: 'nudity',
yes_female_nudity: 'nudity',
general_suggestive: 'sexual',
very_bloody: 'gore',
human_corpse: 'corpse',
hanging: 'corpse',
}
const labelForClassLessSensitive = {
yes_self_harm: 'self-harm',
yes_nazi: 'icon-nazi',
yes_kkk: 'icon-kkk',
yes_confederate: 'icon-confederate',
}
export const summarizeLabels = (classes: HiveRespClass[]): string[] => {
const labels: string[] = []
const labels: string[] = sexualLabels(classes)
for (const cls of classes) {
if (labelForClass[cls.class] && cls.score >= 0.9) {
labels.push(labelForClass[cls.class])
}
}
for (const cls of classes) {
if (labelForClassLessSensitive[cls.class] && cls.score >= 0.96) {
labels.push(labelForClassLessSensitive[cls.class])
}
}
return labels
}

@ -102,6 +102,7 @@ import * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActor
import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
@ -1041,6 +1042,13 @@ export class UnspeccedNS {
this._server = server
}
applyLabels<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyUnspeccedApplyLabels.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.unspecced.applyLabels' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getPopular<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyUnspeccedGetPopular.Handler<ExtractAuth<AV>>>,
) {

@ -536,7 +536,6 @@ export const schemaDict = {
},
moderation: {
type: 'object',
required: [],
properties: {
currentAction: {
type: 'ref',
@ -708,7 +707,7 @@ export const schemaDict = {
note: {
type: 'string',
description:
'Additionally add a note describing why the invites were disabled',
'Additionally add a note describing why the invites were enabled',
},
},
},
@ -1668,7 +1667,7 @@ export const schemaDict = {
},
reasonSexual: {
type: 'token',
description: 'Unwanted or mis-labeled sexual content',
description: 'Unwanted or mislabeled sexual content',
},
reasonRude: {
type: 'token',
@ -6294,6 +6293,32 @@ export const schemaDict = {
},
},
},
AppBskyUnspeccedApplyLabels: {
lexicon: 1,
id: 'app.bsky.unspecced.applyLabels',
defs: {
main: {
type: 'procedure',
description: 'Allow a labeler to apply labels directly.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['labels'],
properties: {
labels: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#label',
},
},
},
},
},
},
},
},
AppBskyUnspeccedGetPopular: {
lexicon: 1,
id: 'app.bsky.unspecced.getPopular',
@ -6561,6 +6586,7 @@ export const ids = {
'app.bsky.notification.listNotifications',
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
AppBskyUnspeccedGetPopularFeedGenerators:
'app.bsky.unspecced.getPopularFeedGenerators',

@ -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 ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
export interface QueryParams {}
export interface InputSchema {
labels: ComAtprotoLabelDefs.Label[]
[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

@ -12,7 +12,7 @@ export interface QueryParams {}
export interface InputSchema {
account: string
/** Additionally add a note describing why the invites were disabled */
/** Additionally add a note describing why the invites were enabled */
note?: string
[k: string]: unknown
}

@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
/** Misleading identity, affiliation, or content */
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
/** Unwanted or mis-labeled sexual content */
/** Unwanted or mislabeled sexual content */
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'

@ -1,14 +1,16 @@
import { NotEmptyArray } from '@atproto/common'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import { FeedKeyset } from './util/feed'
import { GenericKeyset, paginate } from '../../../../db/pagination'
import AppContext from '../../../../context'
import { FeedRow } from '../../../services/feed'
import { isPostView } from '../../../../lexicon/types/app/bsky/feed/defs'
import { NotEmptyArray } from '@atproto/common'
import {
isPostView,
GeneratorView,
} from '../../../../lexicon/types/app/bsky/feed/defs'
import { isViewRecord } from '../../../../lexicon/types/app/bsky/embed/record'
import { countAll, valuesList } from '../../../../db/util'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { GeneratorView } from '@atproto/api/src/client/types/app/bsky/feed/defs'
import { FeedKeyset } from './util/feed'
const NO_WHATS_HOT_LABELS: NotEmptyArray<string> = [
'!no-promote',
@ -189,6 +191,18 @@ export default function (server: Server, ctx: AppContext) {
}
},
})
server.app.bsky.unspecced.applyLabels({
auth: ctx.roleVerifier,
handler: async ({ auth, input }) => {
if (!auth.credentials.admin) {
throw new AuthRequiredError('Insufficient privileges')
}
const { services, db } = ctx
const { labels } = input.body
await services.appView.label(db).createLabels(labels)
},
})
}
type Result = { likeCount: number; cid: string }

@ -102,6 +102,7 @@ import * as AppBskyGraphUnmuteActorList from './types/app/bsky/graph/unmuteActor
import * as AppBskyNotificationGetUnreadCount from './types/app/bsky/notification/getUnreadCount'
import * as AppBskyNotificationListNotifications from './types/app/bsky/notification/listNotifications'
import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen'
import * as AppBskyUnspeccedApplyLabels from './types/app/bsky/unspecced/applyLabels'
import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular'
import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators'
import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton'
@ -1041,6 +1042,13 @@ export class UnspeccedNS {
this._server = server
}
applyLabels<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyUnspeccedApplyLabels.Handler<ExtractAuth<AV>>>,
) {
const nsid = 'app.bsky.unspecced.applyLabels' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getPopular<AV extends AuthVerifier>(
cfg: ConfigOf<AV, AppBskyUnspeccedGetPopular.Handler<ExtractAuth<AV>>>,
) {

@ -536,7 +536,6 @@ export const schemaDict = {
},
moderation: {
type: 'object',
required: [],
properties: {
currentAction: {
type: 'ref',
@ -708,7 +707,7 @@ export const schemaDict = {
note: {
type: 'string',
description:
'Additionally add a note describing why the invites were disabled',
'Additionally add a note describing why the invites were enabled',
},
},
},
@ -1668,7 +1667,7 @@ export const schemaDict = {
},
reasonSexual: {
type: 'token',
description: 'Unwanted or mis-labeled sexual content',
description: 'Unwanted or mislabeled sexual content',
},
reasonRude: {
type: 'token',
@ -6294,6 +6293,32 @@ export const schemaDict = {
},
},
},
AppBskyUnspeccedApplyLabels: {
lexicon: 1,
id: 'app.bsky.unspecced.applyLabels',
defs: {
main: {
type: 'procedure',
description: 'Allow a labeler to apply labels directly.',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['labels'],
properties: {
labels: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:com.atproto.label.defs#label',
},
},
},
},
},
},
},
},
AppBskyUnspeccedGetPopular: {
lexicon: 1,
id: 'app.bsky.unspecced.getPopular',
@ -6561,6 +6586,7 @@ export const ids = {
'app.bsky.notification.listNotifications',
AppBskyNotificationUpdateSeen: 'app.bsky.notification.updateSeen',
AppBskyRichtextFacet: 'app.bsky.richtext.facet',
AppBskyUnspeccedApplyLabels: 'app.bsky.unspecced.applyLabels',
AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular',
AppBskyUnspeccedGetPopularFeedGenerators:
'app.bsky.unspecced.getPopularFeedGenerators',

@ -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 ComAtprotoLabelDefs from '../../../com/atproto/label/defs'
export interface QueryParams {}
export interface InputSchema {
labels: ComAtprotoLabelDefs.Label[]
[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

@ -12,7 +12,7 @@ export interface QueryParams {}
export interface InputSchema {
account: string
/** Additionally add a note describing why the invites were disabled */
/** Additionally add a note describing why the invites were enabled */
note?: string
[k: string]: unknown
}

@ -21,7 +21,7 @@ export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam'
export const REASONVIOLATION = 'com.atproto.moderation.defs#reasonViolation'
/** Misleading identity, affiliation, or content */
export const REASONMISLEADING = 'com.atproto.moderation.defs#reasonMisleading'
/** Unwanted or mis-labeled sexual content */
/** Unwanted or mislabeled sexual content */
export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
/** Rude, harassing, explicit, or otherwise unwelcoming behavior */
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'

@ -0,0 +1,187 @@
import AtpAgent from '@atproto/api'
import {
adminAuth,
CloseFn,
moderatorAuth,
runTestServer,
TestServerInfo,
} from '../_util'
import { SeedClient } from '../seeds/client'
import basicSeed from '../seeds/basic'
describe('unspecced.applyLabels', () => {
let server: TestServerInfo
let close: CloseFn
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
server = await runTestServer({
dbPostgresSchema: 'moderation',
})
close = server.close
agent = new AtpAgent({ service: server.url })
sc = new SeedClient(agent)
await basicSeed(sc)
})
afterAll(async () => {
await close()
})
it('requires admin auth.', async () => {
const tryToLabel = agent.api.app.bsky.unspecced.applyLabels(
{
labels: [
{
src: server.ctx.cfg.labelerDid,
uri: sc.dids.carol,
val: 'cats',
neg: false,
cts: new Date().toISOString(),
},
],
},
{
encoding: 'application/json',
headers: { authorization: moderatorAuth() },
},
)
await expect(tryToLabel).rejects.toThrow('Insufficient privileges')
})
it('adds and removes labels on record as though applied by the labeler.', async () => {
const post = sc.posts[sc.dids.bob][1].ref
await agent.api.app.bsky.unspecced.applyLabels(
{
labels: [
{
src: server.ctx.cfg.labelerDid,
uri: post.uriStr,
cid: post.cidStr,
val: 'birds',
neg: false,
cts: new Date().toISOString(),
},
{
src: server.ctx.cfg.labelerDid,
uri: post.uriStr,
cid: post.cidStr,
val: 'bats',
neg: false,
cts: new Date().toISOString(),
},
],
},
{
encoding: 'application/json',
headers: { authorization: adminAuth() },
},
)
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([
'birds',
'bats',
])
await agent.api.app.bsky.unspecced.applyLabels(
{
labels: [
{
src: server.ctx.cfg.labelerDid,
uri: post.uriStr,
cid: post.cidStr,
val: 'birds',
neg: true,
cts: new Date().toISOString(),
},
{
src: server.ctx.cfg.labelerDid,
uri: post.uriStr,
cid: post.cidStr,
val: 'bats',
neg: true,
cts: new Date().toISOString(),
},
],
},
{
encoding: 'application/json',
headers: { authorization: adminAuth() },
},
)
await expect(getRecordLabels(post.uriStr)).resolves.toEqual([])
})
it('adds and removes labels on repo as though applied by the labeler.', async () => {
await agent.api.app.bsky.unspecced.applyLabels(
{
labels: [
{
src: server.ctx.cfg.labelerDid,
uri: sc.dids.carol,
val: 'birds',
neg: false,
cts: new Date().toISOString(),
},
{
src: server.ctx.cfg.labelerDid,
uri: sc.dids.carol,
val: 'bats',
neg: false,
cts: new Date().toISOString(),
},
],
},
{
encoding: 'application/json',
headers: { authorization: adminAuth() },
},
)
await expect(getRepoLabels(sc.dids.carol)).resolves.toEqual([
'birds',
'bats',
])
await agent.api.app.bsky.unspecced.applyLabels(
{
labels: [
{
src: server.ctx.cfg.labelerDid,
uri: sc.dids.carol,
val: 'birds',
neg: true,
cts: new Date().toISOString(),
},
{
src: server.ctx.cfg.labelerDid,
uri: sc.dids.carol,
val: 'bats',
neg: true,
cts: new Date().toISOString(),
},
],
},
{
encoding: 'application/json',
headers: { authorization: adminAuth() },
},
)
await expect(getRepoLabels(sc.dids.carol)).resolves.toEqual([])
})
async function getRecordLabels(uri: string) {
const result = await agent.api.com.atproto.admin.getRecord(
{ uri },
{ headers: { authorization: adminAuth() } },
)
const labels = result.data.labels ?? []
return labels.map((l) => l.val)
}
async function getRepoLabels(did: string) {
const result = await agent.api.com.atproto.admin.getRepo(
{ did },
{ headers: { authorization: adminAuth() } },
)
const labels = result.data.labels ?? []
return labels.map((l) => l.val)
}
})